Skip to content

Commit 7ca7482

Browse files
committed
fix(download): honor selected stream resolution for dash downloads
1 parent c371f23 commit 7ca7482

5 files changed

Lines changed: 222 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
- Added `vlc_path`, `mpv_path`, and `iina_path` fields to `Config` in `src/config.rs` and `src/tui/state.rs`.
1515
- Supported persisted custom player paths with automatic static path cache invalidation upon configuration updates.
1616

17+
18+
### Changed
19+
- **Streamlined Download Failure Notifications**:
20+
- Replaced verbose, truncated DASH download failure guidance with terse, actionable commands (`Run: winget install yt-dlp.yt-dlp Gyan.FFmpeg`).
21+
- Removed misleading "Partial file preserved" prefix on pre-download failures in `src/tui/app/download.rs`.
22+
- Scaled notification toast card width to the widest line across multi-line messages in `src/tui/overlay.rs`.
23+
- Threaded selected stream resolution constraint (`bestvideo[height<={h}]...`) to `yt-dlp` in `src/tui/app/download.rs`, ensuring DASH downloads fetch the highlighted quality rather than forcing 1080p.
1724
### Fixed
1825
- **Streaming Proxy & Sidecar Lifecycle**:
1926
- Enforced fail-closed target host verification in `src/proxy.rs`, rejecting requests with `403 Forbidden` if `target_host` is missing or unverified.

‎docs/downloads.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,14 @@ Upon completion, temporary sidecars are verified and renamed atomically to the t
3030
## Header Forwarding
3131

3232
Authenticated streams (such as MovieBox DASH manifests or 4KHDHub mirrors) automatically forward required headers (`User-Agent`, `Referer`, signed CloudFront cookies) to download workers, ensuring CDN transfers complete without `403 Forbidden` errors.
33+
34+
## DASH Streams (`yt-dlp`)
35+
36+
MovieBox DASH streams require `yt-dlp` and `ffmpeg` to download and mux adaptive video/audio representations:
37+
38+
- **Windows**: `winget install yt-dlp.yt-dlp Gyan.FFmpeg`
39+
- **macOS**: `brew install yt-dlp ffmpeg`
40+
- **Android (Termux)**: `pkg install yt-dlp ffmpeg`
41+
- **Linux**: Install `yt-dlp` and `ffmpeg` via system package manager.
42+
43+
All other providers (4KHDHub, BDIX, DhakaFlix, CircleFTP, TV mode) download directly through the internal HTTP engine.

‎src/tui/action.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ pub enum Action {
7979
SelectBrowse(crate::tui::state::BrowsePreset),
8080
SelectAddonCatalog(crate::providers::addons::models::AddonCatalogTarget),
8181
DownloadStream(Option<String>),
82-
StartDownload(Option<String>, Option<String>, Vec<(String, String)>),
82+
StartDownload(
83+
Option<String>,
84+
Option<String>,
85+
Vec<(String, String)>,
86+
Option<u64>,
87+
),
8388
UpdateDownload(Option<f64>, Option<String>),
8489
DownloadCompleted(String),
8590
DownloadFailed(String),

‎src/tui/app/download.rs‎

Lines changed: 169 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ impl App {
1212
subtitle_url: Option<String>,
1313
link: Option<String>,
1414
headers: Vec<(String, String)>,
15+
max_height: Option<u64>,
1516
) {
1617
if self.state.download_progress.is_some() || self.state.active_screen != Screen::Details {
1718
return;
@@ -265,8 +266,9 @@ impl App {
265266
cmd.arg("--add-header").arg(format!("{clean_k}: {clean_v}"));
266267
}
267268
}
269+
let format_spec = ytdlp_format_selector(max_height);
268270
cmd.arg("-f")
269-
.arg("bestvideo+bestaudio/best")
271+
.arg(format_spec)
270272
.arg("--newline")
271273
.arg("--part")
272274
.arg("-o")
@@ -537,11 +539,13 @@ impl App {
537539
|| release.provider == ProviderKind::BdixDhakaFlix
538540
{
539541
let sender_clone = self.action_sender.clone();
542+
let max_height = Some(release.resolution_u64()).filter(|&h| h > 0);
540543
sender_clone
541544
.send(Action::StartDownload(
542545
subtitle_url,
543546
Some(first_mirror.resolver_url.clone()),
544547
first_mirror.headers.clone(),
548+
max_height,
545549
))
546550
.ok();
547551
return None;
@@ -571,11 +575,14 @@ impl App {
571575
.await;
572576
match result {
573577
Ok(Ok(source)) => {
578+
let max_height =
579+
Some(release.resolution_u64()).filter(|&h| h > 0);
574580
sender
575581
.send(Action::StartDownload(
576582
subtitle_url,
577583
Some(source.url),
578584
source.headers,
585+
max_height,
579586
))
580587
.ok();
581588
}
@@ -600,7 +607,7 @@ impl App {
600607
});
601608
} else {
602609
self.action_sender
603-
.send(Action::StartDownload(subtitle_url, None, Vec::new()))
610+
.send(Action::StartDownload(subtitle_url, None, Vec::new(), None))
604611
.ok();
605612
}
606613
} else {
@@ -611,15 +618,24 @@ impl App {
611618
.and_then(|r| r.mirrors.first())
612619
.map(|m| m.headers.clone())
613620
.unwrap_or_default();
621+
let max_height = release
622+
.as_ref()
623+
.map(|r| r.resolution_u64())
624+
.filter(|&h| h > 0);
614625
self.action_sender
615-
.send(Action::StartDownload(subtitle_url, link, headers))
626+
.send(Action::StartDownload(
627+
subtitle_url,
628+
link,
629+
headers,
630+
max_height,
631+
))
616632
.ok();
617633
}
618634
return None;
619635
}
620-
Action::StartDownload(subtitle_url, link, headers) => {
636+
Action::StartDownload(subtitle_url, link, headers, max_height) => {
621637
self.state.is_resolving_playback = false;
622-
self.start_resilient_download(subtitle_url, link, headers);
638+
self.start_resilient_download(subtitle_url, link, headers, max_height);
623639
return None;
624640
}
625641
Action::DownloadEpisode => {
@@ -793,11 +809,8 @@ impl App {
793809
format!("{completed}/{total} files finished before error: {error}"),
794810
);
795811
} else {
796-
self.state.notify(
797-
NotificationKind::Error,
798-
"Download failed",
799-
format!("Partial file preserved. {error}"),
800-
);
812+
self.state
813+
.notify(NotificationKind::Error, "Download failed", error);
801814
}
802815
self.state.download_queue.clear();
803816
self.state.download_queue_total = 0;
@@ -998,15 +1011,25 @@ async fn prepare_target_dir(
9981011
}
9991012
pub(crate) fn yt_dlp_missing_guidance() -> String {
10001013
if crate::updater::artifact::is_termux_environment() {
1001-
"MovieBox DASH streams require yt-dlp. Please install yt-dlp and ffmpeg on your device (e.g. 'pkg install yt-dlp ffmpeg') to download these streams.".to_string()
1014+
"DASH streams require yt-dlp & ffmpeg.\nRun: pkg install yt-dlp ffmpeg".to_string()
10021015
} else if cfg!(target_os = "macos") {
1003-
"MovieBox DASH streams require yt-dlp. Please install yt-dlp and ffmpeg on your Mac (e.g. 'brew install yt-dlp ffmpeg') to download these streams.".to_string()
1016+
"DASH streams require yt-dlp & ffmpeg.\nRun: brew install yt-dlp ffmpeg".to_string()
10041017
} else if cfg!(target_os = "windows") {
1005-
"MovieBox DASH streams require yt-dlp. Please install yt-dlp and ffmpeg on your system (e.g. 'winget install yt-dlp.yt-dlp Gyan.FFmpeg') to download these streams.".to_string()
1018+
"DASH streams require yt-dlp & ffmpeg.\nRun: winget install yt-dlp.yt-dlp Gyan.FFmpeg"
1019+
.to_string()
10061020
} else if cfg!(target_os = "linux") {
1007-
"MovieBox DASH streams require yt-dlp. Please install yt-dlp and ffmpeg via your system package manager to download these streams.".to_string()
1021+
"DASH streams require yt-dlp & ffmpeg.\nInstall via system package manager".to_string()
1022+
} else {
1023+
"DASH streams require yt-dlp & ffmpeg.".to_string()
1024+
}
1025+
}
1026+
pub(crate) fn ytdlp_format_selector(max_height: Option<u64>) -> String {
1027+
if let Some(height) = max_height.filter(|&h| h > 0) {
1028+
format!(
1029+
"bestvideo[height<={height}]+bestaudio/best[height<={height}]/bestvideo+bestaudio/best"
1030+
)
10081031
} else {
1009-
"MovieBox DASH streams require yt-dlp. Please install yt-dlp and ffmpeg on your system to download these streams.".to_string()
1032+
"bestvideo+bestaudio/best".to_string()
10101033
}
10111034
}
10121035

@@ -1093,6 +1116,7 @@ pub(crate) fn parse_ytdlp_progress(line: &str) -> Option<(f64, String)> {
10931116

10941117
#[cfg(test)]
10951118
mod tests {
1119+
use super::*;
10961120
use crate::providers::models::{ProviderKind, Release, SourceMirror};
10971121
use crate::tui::action::Action;
10981122
use crate::tui::app::App;
@@ -1195,8 +1219,11 @@ mod tests {
11951219
#[test]
11961220
fn test_yt_dlp_missing_guidance_contains_platform_hint() {
11971221
let guidance = super::yt_dlp_missing_guidance();
1198-
assert!(guidance.contains("MovieBox DASH streams require yt-dlp"));
1199-
assert!(guidance.contains("install yt-dlp and ffmpeg"));
1222+
assert!(guidance.contains("DASH streams require yt-dlp & ffmpeg"));
1223+
#[cfg(target_os = "macos")]
1224+
assert!(guidance.contains("brew install yt-dlp ffmpeg"));
1225+
#[cfg(target_os = "windows")]
1226+
assert!(guidance.contains("winget install yt-dlp.yt-dlp Gyan.FFmpeg"));
12001227
}
12011228

12021229
#[tokio::test]
@@ -1230,7 +1257,8 @@ mod tests {
12301257

12311258
let dispatched = app.action_receiver.try_recv().expect("action dispatched");
12321259
match dispatched {
1233-
Action::StartDownload(_, link, headers) => {
1260+
Action::StartDownload(_, link, headers, max_height) => {
1261+
assert_eq!(max_height, Some(1080));
12341262
assert_eq!(
12351263
link.as_deref(),
12361264
Some("https://example.com/dash/123/index.mpd")
@@ -1244,6 +1272,129 @@ mod tests {
12441272
other => panic!("expected StartDownload, got {:?}", other),
12451273
}
12461274
}
1275+
#[tokio::test]
1276+
async fn test_download_stream_respects_highlighted_resolution() {
1277+
let mut app = App::new();
1278+
app.state.active_provider = ProviderKind::MovieBox;
1279+
app.state.active_screen = Screen::Details;
1280+
app.state.selected_resources = vec![
1281+
Release {
1282+
provider: ProviderKind::MovieBox,
1283+
filename: "Ek Deewane Ki Deewaniyat 1080p HEVC".to_string(),
1284+
quality: Some("1080p".to_string()),
1285+
codec: Some("hevc".to_string()),
1286+
language: None,
1287+
size_bytes: Some(1_600_000_000),
1288+
season: None,
1289+
episode: None,
1290+
mirrors: vec![SourceMirror {
1291+
label: "1080p HEVC".to_string(),
1292+
resolver_url: "https://example.com/dash/123/index.mpd".to_string(),
1293+
headers: vec![],
1294+
direct_file: true,
1295+
}],
1296+
resource_id: Some("123".to_string()),
1297+
},
1298+
Release {
1299+
provider: ProviderKind::MovieBox,
1300+
filename: "Ek Deewane Ki Deewaniyat 720p HEVC".to_string(),
1301+
quality: Some("720p".to_string()),
1302+
codec: Some("hevc".to_string()),
1303+
language: None,
1304+
size_bytes: Some(839_000_000),
1305+
season: None,
1306+
episode: None,
1307+
mirrors: vec![SourceMirror {
1308+
label: "720p HEVC".to_string(),
1309+
resolver_url: "https://example.com/dash/123/index.mpd".to_string(),
1310+
headers: vec![],
1311+
direct_file: true,
1312+
}],
1313+
resource_id: Some("123".to_string()),
1314+
},
1315+
Release {
1316+
provider: ProviderKind::MovieBox,
1317+
filename: "Ek Deewane Ki Deewaniyat 480p HEVC".to_string(),
1318+
quality: Some("480p".to_string()),
1319+
codec: Some("hevc".to_string()),
1320+
language: None,
1321+
size_bytes: Some(438_000_000),
1322+
season: None,
1323+
episode: None,
1324+
mirrors: vec![SourceMirror {
1325+
label: "480p HEVC".to_string(),
1326+
resolver_url: "https://example.com/dash/123/index.mpd".to_string(),
1327+
headers: vec![],
1328+
direct_file: true,
1329+
}],
1330+
resource_id: Some("123".to_string()),
1331+
},
1332+
];
1333+
1334+
app.state.resource_list_state.select(Some(2));
1335+
assert_eq!(
1336+
app.get_selected_release().unwrap().quality.as_deref(),
1337+
Some("480p")
1338+
);
1339+
1340+
app.handle_download(Action::DownloadStream(None)).await;
1341+
1342+
let dispatched = app.action_receiver.try_recv().expect("action dispatched");
1343+
match dispatched {
1344+
Action::StartDownload(_, link, _, max_height) => {
1345+
assert_eq!(
1346+
link.as_deref(),
1347+
Some("https://example.com/dash/123/index.mpd")
1348+
);
1349+
assert_eq!(max_height, Some(480));
1350+
let format_spec = ytdlp_format_selector(max_height);
1351+
assert_eq!(
1352+
format_spec,
1353+
"bestvideo[height<=480]+bestaudio/best[height<=480]/bestvideo+bestaudio/best"
1354+
);
1355+
}
1356+
other => panic!("expected StartDownload, got {:?}", other),
1357+
}
1358+
app.state.is_resolving_playback = false;
1359+
app.state.resource_list_state.select(Some(1));
1360+
assert_eq!(
1361+
app.get_selected_release().unwrap().quality.as_deref(),
1362+
Some("720p")
1363+
);
1364+
1365+
app.handle_download(Action::DownloadStream(None)).await;
1366+
1367+
let dispatched = app.action_receiver.try_recv().expect("action dispatched");
1368+
match dispatched {
1369+
Action::StartDownload(_, _, _, max_height) => {
1370+
assert_eq!(max_height, Some(720));
1371+
let format_spec = ytdlp_format_selector(max_height);
1372+
assert_eq!(
1373+
format_spec,
1374+
"bestvideo[height<=720]+bestaudio/best[height<=720]/bestvideo+bestaudio/best"
1375+
);
1376+
}
1377+
other => panic!("expected StartDownload, got {:?}", other),
1378+
}
1379+
}
1380+
1381+
#[test]
1382+
fn test_ytdlp_format_selector_specs() {
1383+
assert_eq!(
1384+
ytdlp_format_selector(Some(480)),
1385+
"bestvideo[height<=480]+bestaudio/best[height<=480]/bestvideo+bestaudio/best"
1386+
);
1387+
assert_eq!(
1388+
ytdlp_format_selector(Some(720)),
1389+
"bestvideo[height<=720]+bestaudio/best[height<=720]/bestvideo+bestaudio/best"
1390+
);
1391+
assert_eq!(
1392+
ytdlp_format_selector(Some(1080)),
1393+
"bestvideo[height<=1080]+bestaudio/best[height<=1080]/bestvideo+bestaudio/best"
1394+
);
1395+
assert_eq!(ytdlp_format_selector(None), "bestvideo+bestaudio/best");
1396+
assert_eq!(ytdlp_format_selector(Some(0)), "bestvideo+bestaudio/best");
1397+
}
12471398
#[test]
12481399
fn test_download_directory_and_filename_conventions() {
12491400
let base_dir = std::path::PathBuf::from("/tmp/MovieBox-TUI");

‎src/tui/overlay.rs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,13 @@ pub fn notifications(
460460
let title_w = crate::tui::text::width(&notification.title).saturating_add(6);
461461
let badge_w = crate::tui::text::width(badge).saturating_add(6);
462462
let raw_msg_w = if has_message {
463-
crate::tui::text::width(&notification.message).saturating_add(6)
463+
notification
464+
.message
465+
.lines()
466+
.map(|line| crate::tui::text::width(line.trim()))
467+
.max()
468+
.unwrap_or(0)
469+
.saturating_add(6)
464470
} else {
465471
0
466472
};
@@ -700,7 +706,13 @@ pub fn notification_rects(
700706
let title_w = crate::tui::text::width(&notification.title).saturating_add(6);
701707
let badge_w = crate::tui::text::width(badge).saturating_add(6);
702708
let raw_msg_w = if has_message {
703-
crate::tui::text::width(&notification.message).saturating_add(6)
709+
notification
710+
.message
711+
.lines()
712+
.map(|line| crate::tui::text::width(line.trim()))
713+
.max()
714+
.unwrap_or(0)
715+
.saturating_add(6)
704716
} else {
705717
0
706718
};
@@ -1088,6 +1100,21 @@ mod tests {
10881100
assert!(rects[0].1.height >= 5);
10891101
}
10901102
#[test]
1103+
fn test_notification_rects_multiline_width_scales_to_longest_line() {
1104+
let mut queue = std::collections::VecDeque::new();
1105+
queue.push_back(Notification::new(
1106+
NotificationKind::Error,
1107+
"Download failed",
1108+
"DASH streams require yt-dlp & ffmpeg.\nRun: winget install yt-dlp.yt-dlp Gyan.FFmpeg",
1109+
));
1110+
let area = Rect::new(0, 0, 100, 30);
1111+
let rects = notification_rects(area, &queue, false, false);
1112+
assert_eq!(rects.len(), 1);
1113+
assert!(rects[0].1.width >= 48);
1114+
assert!(rects[0].1.width <= 64);
1115+
assert_eq!(rects[0].1.height, 5);
1116+
}
1117+
#[test]
10911118
fn test_addon_manager_layout_anchors_at_search_bar_position() {
10921119
let standard_area = Rect::new(0, 0, 80, 24);
10931120
let layout = addon_manager_layout(standard_area, 2, 10, false);

0 commit comments

Comments
 (0)