diff --git a/src/app.rs b/src/app.rs index 50a842c..fd28479 100644 --- a/src/app.rs +++ b/src/app.rs @@ -233,6 +233,9 @@ struct ChannelOptionsForm { subs_embed_idx: usize, subtitle_format: String, // "" = global default youtube_player_clients: String, // "" = global default + // Per-channel convert override: 0=Default(global), 1=Off, 2=remux-mp4, + // 3=h264-mp4, 4=audio. Maps to Option on save. + convert_idx: usize, extra_args: String, // one per line } @@ -245,6 +248,27 @@ fn idx_to_tri(i: usize) -> Option { match i { 1 => Some(true), 2 => Some(false), _ => None } } +/// Per-channel convert-mode override ⇄ combo index. +/// 0=Default (None, defer to global), 1=Off, 2=remux-mp4, 3=h264-mp4, 4=audio. +fn convert_mode_to_idx(v: &Option) -> usize { + match v.as_deref() { + Some("off") => 1, + Some("remux-mp4") => 2, + Some("h264-mp4") => 3, + Some("audio") => 4, + _ => 0, + } +} +fn idx_to_convert_mode(i: usize) -> Option { + match i { + 1 => Some("off".into()), + 2 => Some("remux-mp4".into()), + 3 => Some("h264-mp4".into()), + 4 => Some("audio".into()), + _ => None, + } +} + impl App { pub fn new(cc: &eframe::CreationContext<'_>, tray: Option) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); @@ -316,6 +340,7 @@ impl App { ); downloader.subtitle_defaults = config.subtitles.clone(); downloader.youtube_player_clients = config.backup.youtube_player_clients.clone(); + downloader.convert_defaults = config.convert.clone(); let config_bind = config.web.bind.clone(); let password_set = db.get_setting("password_hash").ok().flatten().is_some(); let plex_path_str = config.plex.library_path @@ -477,6 +502,7 @@ impl App { subs_embed_idx: tri_to_idx(opts.subtitles_embed), subtitle_format: opts.subtitle_format.clone().unwrap_or_default(), youtube_player_clients: opts.youtube_player_clients.clone().unwrap_or_default(), + convert_idx: convert_mode_to_idx(&opts.convert_mode), extra_args: opts.extra_args.join("\n"), } } @@ -514,6 +540,7 @@ impl App { subtitles_embed: idx_to_tri(f.subs_embed_idx), subtitle_format: trim_opt(&f.subtitle_format), youtube_player_clients: trim_opt(&f.youtube_player_clients), + convert_mode: idx_to_convert_mode(f.convert_idx), extra_args: f.extra_args.lines() .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), } @@ -2035,6 +2062,27 @@ impl App { bot-checked."); }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Convert"); + let label = match form.convert_idx { + 1 => "Off", 2 => "Remux → mp4", 3 => "H.264 mp4", 4 => "Audio", + _ => "Default (global)", + }; + egui::ComboBox::from_id_salt("ch_convert") + .selected_text(label) + .show_ui(ui, |ui| { + ui.selectable_value(&mut form.convert_idx, 0, "Default (global)"); + ui.selectable_value(&mut form.convert_idx, 1, "Off"); + ui.selectable_value(&mut form.convert_idx, 2, "Remux → mp4"); + ui.selectable_value(&mut form.convert_idx, 3, "H.264 mp4"); + ui.selectable_value(&mut form.convert_idx, 4, "Audio"); + }); + }).response.on_hover_text( + "Post-download ffmpeg conversion for this channel. Default = use the \ + global Format-conversion setting. CRF / preset / audio-format come \ + from the global config."); + ui.add_space(6.0); ui.label("Extra yt-dlp args (one per line):"); ui.add( @@ -2651,6 +2699,69 @@ impl App { }); }); + ui.add_space(8.0); + ui.separator(); + ui.heading("Format conversion (global defaults)"); + ui.add_space(4.0); + ui.label( + egui::RichText::new( + "Runs ffmpeg after each download. Individual channels can override \ + the mode in their Channel options. Requires ffmpeg on PATH.", + ).small().weak(), + ); + let cv = &mut self.config.convert; + ui.horizontal(|ui| { + ui.label("Mode:"); + let mode_label = match cv.mode.as_str() { + "remux-mp4" => "Remux → mp4 (no re-encode)", + "h264-mp4" => "Re-encode → H.264 mp4", + "audio" => "Extract audio", + _ => "Off", + }; + egui::ComboBox::from_id_salt("convert_mode") + .selected_text(mode_label) + .show_ui(ui, |ui| { + ui.selectable_value(&mut cv.mode, String::new(), "Off"); + ui.selectable_value(&mut cv.mode, "remux-mp4".into(), "Remux → mp4 (no re-encode)"); + ui.selectable_value(&mut cv.mode, "h264-mp4".into(), "Re-encode → H.264 mp4"); + ui.selectable_value(&mut cv.mode, "audio".into(), "Extract audio"); + }); + }); + if cv.mode == "h264-mp4" { + ui.horizontal(|ui| { + ui.label("CRF (quality):"); + ui.add(egui::DragValue::new(&mut cv.crf).range(0..=51)) + .on_hover_text("0–51, lower = bigger/better. 23 is a good default."); + if cv.crf == 0 { cv.crf = 23; } + ui.label("Preset:"); + let preset_disp = if cv.preset.is_empty() { "medium" } else { cv.preset.as_str() }; + egui::ComboBox::from_id_salt("convert_preset") + .selected_text(preset_disp) + .show_ui(ui, |ui| { + for p in ["ultrafast","superfast","veryfast","faster","fast","medium","slow","slower","veryslow"] { + ui.selectable_value(&mut cv.preset, p.to_string(), p); + } + }); + }); + } + if cv.mode == "audio" { + ui.horizontal(|ui| { + ui.label("Audio format:"); + let af = if cv.audio_format.is_empty() { "mp3" } else { cv.audio_format.as_str() }; + egui::ComboBox::from_id_salt("convert_audio_fmt") + .selected_text(af) + .show_ui(ui, |ui| { + for f in ["mp3","m4a","opus","flac"] { + ui.selectable_value(&mut cv.audio_format, f.to_string(), f); + } + }); + }); + } + if !cv.mode.is_empty() { + ui.checkbox(&mut cv.keep_original, "Keep original file alongside the converted one") + .on_hover_text("Renames the source to .original.. When off, the original is deleted after a successful convert."); + } + ui.add_space(8.0); ui.separator(); ui.heading("Backup"); @@ -2844,6 +2955,7 @@ impl App { self.downloader.use_pot_provider = self.config.backup.use_pot_provider; self.downloader.subtitle_defaults = self.config.subtitles.clone(); self.downloader.youtube_player_clients = self.config.backup.youtube_player_clients.clone(); + self.downloader.convert_defaults = self.config.convert.clone(); if dir_changed { self.channels_root = new_dir.clone(); self.library_root = new_dir diff --git a/src/config.rs b/src/config.rs index d65cdbe..0a2b574 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,38 @@ pub struct Config { pub plex: PlexSection, #[serde(default)] pub subtitles: SubtitlesSection, + #[serde(default)] + pub convert: ConvertSection, +} + +/// `[convert]` table — global post-download format-conversion defaults. +/// A separate ffmpeg pass runs after the download completes. Per-channel +/// [`crate::download_options::DownloadOptions`] can override `mode`. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ConvertSection { + /// What to do after download. One of: + /// - `""` / `"off"`: nothing (keep the downloaded mkv as-is). + /// - `"remux-mp4"`: fast container change mkv→mp4, no re-encode. + /// - `"h264-mp4"`: re-encode video to H.264/AAC mp4 at [`Self::crf`]. + /// - `"audio"`: extract audio to [`Self::audio_format`]. + #[serde(default)] + pub mode: String, + /// CRF for `h264-mp4` (0–51; lower = bigger/better). 23 is a sane + /// default if left at 0. + #[serde(default)] + pub crf: u8, + /// x264 preset for `h264-mp4` (ultrafast…veryslow). Empty = "medium". + #[serde(default)] + pub preset: String, + /// Audio format for `audio` mode (mp3 / m4a / opus / flac). Empty = + /// "mp3". + #[serde(default)] + pub audio_format: String, + /// Keep the original downloaded file alongside the converted one + /// (renamed to `.original.`). When false, the original is + /// deleted after a successful convert. + #[serde(default)] + pub keep_original: bool, } /// `[subtitles]` table — global subtitle download defaults. Per-channel @@ -230,6 +262,7 @@ impl Config { web: WebSection::default(), plex: PlexSection::default(), subtitles: SubtitlesSection::default(), + convert: ConvertSection::default(), } } } diff --git a/src/download_options.rs b/src/download_options.rs index bc8de2f..e64ff90 100644 --- a/src/download_options.rs +++ b/src/download_options.rs @@ -92,6 +92,14 @@ pub struct DownloadOptions { #[serde(default)] pub youtube_player_clients: Option, + /// Per-channel post-download conversion mode override. `None` defers + /// to the global `[convert]` config; `Some("off")` forces no + /// conversion for this channel even when the global default is on. + /// Other values: "remux-mp4" / "h264-mp4" / "audio". CRF / preset / + /// audio-format always come from the global config. + #[serde(default)] + pub convert_mode: Option, + /// Raw passthrough — every entry is appended as a separate argument to /// yt-dlp. Lets users access any flag we haven't exposed yet. Equivalent /// to Tartube's `extra_cmd_string`. diff --git a/src/downloader.rs b/src/downloader.rs index e095910..feff501 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -144,6 +144,13 @@ pub struct Job { /// Set once we've evaluated this job's failure for auto-retry, so the /// per-poll scan doesn't schedule the same failure repeatedly. retry_handled: bool, + /// Post-download conversion to run on this job's finished files. + /// `None` for jobs that don't convert (most jobs). Consumed once when + /// the job transitions to Done. + convert_on_finish: Option, + /// Set once we've enqueued the convert pass for this finished job, so + /// the per-poll scan doesn't enqueue it twice. + convert_handled: bool, rx: Receiver, } @@ -158,6 +165,46 @@ struct RetrySpec { opts: Option, } +/// Resolved post-download conversion settings, attached to a download Job +/// so the downloader knows what ffmpeg pass (if any) to run on each +/// finished file. `mode` is one of remux-mp4 / h264-mp4 / audio; the +/// other fields parameterise it. +#[derive(Clone)] +pub struct ConvertSpec { + pub mode: String, + pub crf: u8, + pub preset: String, + pub audio_format: String, + pub keep_original: bool, +} + +impl ConvertSpec { + /// Build a [`ConvertSpec`] from the global `[convert]` config merged + /// with a per-channel `convert_mode` override. Returns `None` when the + /// effective mode is off/empty (no transcode pass). + fn resolve( + global: &crate::config::ConvertSection, + opts: Option<&DownloadOptions>, + ) -> Option { + let mode = opts + .and_then(|o| o.convert_mode.as_deref()) + .filter(|s| !s.is_empty()) + .unwrap_or(global.mode.as_str()) + .trim() + .to_string(); + if mode.is_empty() || mode == "off" { + return None; + } + Some(ConvertSpec { + mode, + crf: if global.crf == 0 { 23 } else { global.crf }, + preset: if global.preset.is_empty() { "medium".into() } else { global.preset.clone() }, + audio_format: if global.audio_format.is_empty() { "mp3".into() } else { global.audio_format.clone() }, + keep_original: global.keep_original, + }) + } +} + /// Max automatic re-queues per job for transient failures. One retry /// clears most momentary captchas/resets; more than that and it's a real /// block the user needs to act on (refresh cookies, wait longer). @@ -204,6 +251,8 @@ struct PendingJob { retry_spec: Option, /// Attempt number this command represents (0 = first try). retry_count: u8, + /// Post-download convert spec for this job (None = no conversion). + convert_spec: Option, } /// Manages all active, queued, and recently completed yt-dlp download jobs. @@ -235,6 +284,9 @@ pub struct Downloader { /// yt-dlp defaults. Per-channel options can override. Set at /// construction + on settings save. pub youtube_player_clients: String, + /// Global `[convert]` config. Drives the post-download ffmpeg pass. + /// Per-channel options override the mode. Set at construction + save. + pub convert_defaults: crate::config::ConvertSection, /// Scheduled auto-retries: `(fire_at, spec, attempt_number)`. Populated /// when a job fails with a retryable class; drained in [`Self::poll`] /// once `fire_at` passes, re-issuing the download. Kept separate from @@ -253,6 +305,9 @@ pub struct Downloader { /// 0 for a user-initiated download; >0 when [`Self::start_retry`] is /// re-issuing a failed one. Consumed by `enqueue`. retry_attempt_override: u8, + /// Stashed by [`Self::start`], consumed by the next `enqueue` so the + /// download job carries its convert spec. Like `pending_retry_spec`. + pending_convert_spec: Option, } impl Downloader { @@ -274,10 +329,12 @@ impl Downloader { pot_server: None, subtitle_defaults: crate::config::SubtitlesSection::default(), youtube_player_clients: String::new(), + convert_defaults: crate::config::ConvertSection::default(), retry_queue: Vec::new(), rate_limited_backoff: false, pending_retry_spec: None, retry_attempt_override: 0, + pending_convert_spec: None, } } @@ -451,21 +508,22 @@ impl Downloader { if running >= self.max_concurrent { break; } } let p = self.pending.pop_front().unwrap(); - self.spawn_job(p.cmd, p.url, p.label, p.retry_spec, p.retry_count); + self.spawn_job(p.cmd, p.url, p.label, p.retry_spec, p.retry_count, p.convert_spec); } } /// Push a command into the pending queue (or start immediately if a slot is free). /// - /// Consumes `self.pending_retry_spec` (set by [`Self::start`]) so the - /// resulting job carries its retry spec, and `retry_attempt_override` - /// for the attempt number (0 on a first run, >0 from a retry). Other - /// callers (repair, music, updates) leave the spec `None`, making - /// those jobs non-retryable. + /// Consumes `self.pending_retry_spec` + `self.pending_convert_spec` + /// (set by [`Self::start`]) so the resulting job carries them, and + /// `retry_attempt_override` for the attempt number (0 on a first run, + /// >0 from a retry). Other callers (repair, music, updates) leave the + /// specs `None`, making those jobs non-retryable + non-converting. fn enqueue(&mut self, cmd: Command, url: String, label: String) { let retry_spec = self.pending_retry_spec.take(); let retry_count = self.retry_attempt_override; - self.pending.push_back(PendingJob { cmd, url, label, retry_spec, retry_count }); + let convert_spec = self.pending_convert_spec.take(); + self.pending.push_back(PendingJob { cmd, url, label, retry_spec, retry_count, convert_spec }); self.promote_queued(); } @@ -655,6 +713,16 @@ impl Downloader { // YouTube player-client selection (global default + per-channel // override). Lets the user route around a captcha-walled client. self.apply_player_client(&mut cmd, channel_options); + // Post-download conversion: resolve global [convert] + per-channel + // override. When active, ask yt-dlp to print each finished file's + // final path (after_move) so we can enqueue an ffmpeg pass on it. + let convert_spec = ConvertSpec::resolve(&self.convert_defaults, channel_options); + if convert_spec.is_some() { + // The CONVERT_PATH: prefix lets spawn_job's stdout reader + // recognise these lines without confusing them for progress. + cmd.arg("--print").arg("after_move:CONVERT_PATH:%(filepath)s"); + } + self.pending_convert_spec = convert_spec; // Per-channel option overrides win when present — they're applied // last so a `--limit-rate` / `--match-filter` / passthrough arg from // the channel settings takes priority over the global defaults. @@ -774,7 +842,7 @@ impl Downloader { } /// Spawn `cmd` on a background thread, streaming its output into a new [`Job`]. - fn spawn_job(&mut self, mut cmd: Command, url: String, label: String, retry_spec: Option, retry_count: u8) { + fn spawn_job(&mut self, mut cmd: Command, url: String, label: String, retry_spec: Option, retry_count: u8, convert_on_finish: Option) { // POT provider extractor-arg. yt-dlp lets us pass multiple // --extractor-args flags; this one points the bgutil plugin at // our local server. Only emitted when the user opted in *and* @@ -879,6 +947,8 @@ impl Downloader { retry_spec, retry_count, retry_handled: false, + convert_on_finish, + convert_handled: false, rx, }); } @@ -914,6 +984,8 @@ impl Downloader { retry_spec: None, retry_count: 0, retry_handled: true, // never retry a synthetic preflight failure + convert_on_finish: None, + convert_handled: true, rx, }); } @@ -927,6 +999,7 @@ impl Downloader { } self.schedule_auto_retries(); self.fire_due_retries(); + self.schedule_conversions(); // Re-check after draining: finished jobs free slots for queued ones. self.promote_queued(); // Once everything's idle, clear the adaptive backoff so the next @@ -936,6 +1009,33 @@ impl Downloader { } } + /// Scan freshly-Done download jobs that have a convert spec; for each + /// `CONVERT_PATH:` line their yt-dlp run printed, enqueue an ffmpeg + /// transcode job. Marks the job handled so it only fires once. + fn schedule_conversions(&mut self) { + let mut to_convert: Vec<(std::path::PathBuf, ConvertSpec)> = Vec::new(); + for job in &mut self.jobs { + if job.convert_handled || job.state != JobState::Done { continue; } + let Some(spec) = job.convert_on_finish.clone() else { + job.convert_handled = true; + continue; + }; + job.convert_handled = true; + for line in &job.log { + // yt-dlp printed: "CONVERT_PATH:/abs/path/to/file.mkv" + if let Some(p) = line.strip_prefix("CONVERT_PATH:") { + let path = std::path::PathBuf::from(p.trim()); + if path.is_file() { + to_convert.push((path, spec.clone())); + } + } + } + } + for (path, spec) in to_convert { + self.start_transcode(&path, &spec); + } + } + /// Returns true if `class` warrants an automatic retry. Transient /// failures (rate-limit / captcha / network) are worth retrying; a /// removed video or missing codec is not — retrying changes nothing. @@ -1011,6 +1111,159 @@ impl Downloader { self.retry_attempt_override = 0; } + /// Build + enqueue an ffmpeg transcode of `src` per `spec`. The + /// output lands next to the source; the source is renamed to + /// `.original.` (kept) or deleted, depending on + /// `spec.keep_original`. Runs as its own [`Job`] so the UI shows the + /// transcode as a distinct phase. + fn start_transcode(&mut self, src: &std::path::Path, spec: &ConvertSpec) { + let stem = src.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let parent = src.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")); + let src_ext = src.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); + + // Target extension + ffmpeg codec args per mode. + let (out_ext, codec_args): (&str, Vec) = match spec.mode.as_str() { + "remux-mp4" => ("mp4", vec![ + // Stream-copy: no re-encode, just re-container. + "-c".into(), "copy".into(), + // Some MKV audio (opus in mkv) isn't valid in mp4; let + // ffmpeg fall back to aac only if copy fails would need a + // second pass — keep it simple: copy video, aac audio. + "-c:a".into(), "aac".into(), + ]), + "audio" => { + let af = spec.audio_format.as_str(); + let mut a = vec!["-vn".into()]; // drop video + // Pick a reasonable codec per container. + match af { + "mp3" => { a.extend(["-c:a".into(), "libmp3lame".into(), "-q:a".into(), "2".into()]); } + "m4a" | "aac" => { a.extend(["-c:a".into(), "aac".into(), "-b:a".into(), "192k".into()]); } + "opus" => { a.extend(["-c:a".into(), "libopus".into(), "-b:a".into(), "160k".into()]); } + "flac" => { a.extend(["-c:a".into(), "flac".into()]); } + _ => { a.extend(["-c:a".into(), "libmp3lame".into(), "-q:a".into(), "2".into()]); } + } + (if af == "aac" { "m4a" } else { af }, a) + } + // Default "h264-mp4": re-encode to H.264 + AAC at the CRF. + _ => ("mp4", vec![ + "-c:v".into(), "libx264".into(), + "-crf".into(), spec.crf.to_string(), + "-preset".into(), spec.preset.clone(), + "-c:a".into(), "aac".into(), + "-b:a".into(), "192k".into(), + "-movflags".into(), "+faststart".into(), + ]), + }; + + let out_path = parent.join(format!("{stem}.{out_ext}")); + // If the source already has the target extension (e.g. remux-mp4 + // on an mp4), write to a temp name then swap, so we don't clobber + // the input mid-encode. + let same_ext = src_ext.eq_ignore_ascii_case(out_ext); + let work_out = if same_ext { + parent.join(format!("{stem}.converting.{out_ext}")) + } else { + out_path.clone() + }; + + // Build the ffmpeg command. -y overwrites a stale partial output. + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-hide_banner").arg("-loglevel").arg("error").arg("-stats") + .arg("-y") + .arg("-i").arg(src); + for a in &codec_args { cmd.arg(a); } + cmd.arg(&work_out); + + // Post-ffmpeg bookkeeping runs in the job thread via a wrapper + // shell so the rename/delete happens only on ffmpeg success. We + // encode it as a small bash script that runs ffmpeg then fixes up + // the files. + let label = format!("transcode → {out_ext}: {stem}"); + let url = src.display().to_string(); + self.enqueue_transcode(cmd, url, label, src.to_path_buf(), out_path, work_out, same_ext, spec.keep_original); + } + + /// Enqueue a transcode job. We can't easily run post-ffmpeg file + /// bookkeeping (rename original / swap temp) inside the generic + /// spawn_job, so transcode jobs get their own lightweight runner + /// thread that runs ffmpeg then does the file moves on success. + fn enqueue_transcode( + &mut self, + mut cmd: Command, + url: String, + label: String, + src: PathBuf, + out_path: PathBuf, + work_out: PathBuf, + same_ext: bool, + keep_original: bool, + ) { + let (tx, rx) = channel(); + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + thread::spawn(move || { + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + let _ = tx.send(Msg::Line(format!("could not launch ffmpeg: {e}"))); + let _ = tx.send(Msg::Finished(false)); + return; + } + }; + if let Some(stderr) = child.stderr.take() { + let tx = tx.clone(); + thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + let _ = tx.send(Msg::Line(format!("[ffmpeg] {line}"))); + } + }); + } + let ok = matches!(child.wait(), Ok(s) if s.success()); + if ok { + // File bookkeeping on success. + if same_ext { + // src and out share an extension. Keep or drop the + // original, then move the temp output into place. + if keep_original { + let orig = src.with_extension(format!("original.{}", + src.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default())); + let _ = std::fs::rename(&src, &orig); + } else { + let _ = std::fs::remove_file(&src); + } + let _ = std::fs::rename(&work_out, &out_path); + } else { + // Different extensions: output already at out_path. + if keep_original { + let orig = src.with_extension(format!("original.{}", + src.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default())); + let _ = std::fs::rename(&src, &orig); + } else { + let _ = std::fs::remove_file(&src); + } + } + let _ = tx.send(Msg::Line(format!("✓ wrote {}", out_path.display()))); + } else { + // Clean up a partial temp output on failure. + if same_ext { let _ = std::fs::remove_file(&work_out); } + } + let _ = tx.send(Msg::Finished(ok)); + }); + self.jobs.push(Job { + url, + label, + state: JobState::Running, + progress: 0.0, + log: VecDeque::new(), + failure_class: None, + retry_spec: None, // don't auto-retry transcodes + retry_count: 0, + retry_handled: true, + convert_on_finish: None, // a transcode doesn't itself convert + convert_handled: true, + rx, + }); + } + pub fn any_running(&self) -> bool { self.jobs.iter().any(|j| j.state == JobState::Running) } @@ -1194,6 +1447,42 @@ mod tests { assert!(args2.windows(2).any(|w| w == ["--max-sleep-interval", "20"])); } + // ── Convert resolver ───────────────────────────────────────────────── + use crate::config::ConvertSection; + use crate::download_options::DownloadOptions as DO; + + #[test] + fn convert_off_when_global_empty_and_no_override() { + let g = ConvertSection::default(); // mode = "" + assert!(ConvertSpec::resolve(&g, None).is_none()); + } + + #[test] + fn convert_global_h264_defaults_filled() { + let g = ConvertSection { mode: "h264-mp4".into(), crf: 0, preset: String::new(), + audio_format: String::new(), keep_original: false }; + let s = ConvertSpec::resolve(&g, None).unwrap(); + assert_eq!(s.mode, "h264-mp4"); + assert_eq!(s.crf, 23); // 0 → default 23 + assert_eq!(s.preset, "medium"); // empty → default + } + + #[test] + fn convert_per_channel_off_overrides_global_on() { + let g = ConvertSection { mode: "h264-mp4".into(), ..Default::default() }; + let opts = DO { convert_mode: Some("off".into()), ..Default::default() }; + assert!(ConvertSpec::resolve(&g, Some(&opts)).is_none()); + } + + #[test] + fn convert_per_channel_mode_wins() { + let g = ConvertSection { mode: "h264-mp4".into(), ..Default::default() }; + let opts = DO { convert_mode: Some("audio".into()), ..Default::default() }; + let s = ConvertSpec::resolve(&g, Some(&opts)).unwrap(); + assert_eq!(s.mode, "audio"); + assert_eq!(s.audio_format, "mp3"); // empty global → default mp3 + } + // ── Subtitle resolver ──────────────────────────────────────────────── fn dl_with_subs(subs: crate::config::SubtitlesSection) -> Downloader { let mut d = Downloader::new( diff --git a/src/web.rs b/src/web.rs index 6b84fb5..046e149 100644 --- a/src/web.rs +++ b/src/web.rs @@ -370,6 +370,17 @@ struct SettingsPayload { /// YouTube player clients (comma-separated). Empty = yt-dlp defaults. #[serde(default)] youtube_player_clients: String, + /// Global [convert] settings, round-tripped on GET + POST. + #[serde(default)] + convert_mode: String, + #[serde(default)] + convert_crf: u8, + #[serde(default)] + convert_preset: String, + #[serde(default)] + convert_audio_format: String, + #[serde(default)] + convert_keep_original: bool, } #[derive(Serialize, Deserialize, Clone)] @@ -1357,6 +1368,7 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { let use_pot_provider = cfg.backup.use_pot_provider; let subs = cfg.subtitles.clone(); let player_clients_out = cfg.backup.youtube_player_clients.clone(); + let convert = cfg.convert.clone(); drop(cfg); let scheduler_next_check_secs = if scheduler_enabled { @@ -1394,6 +1406,11 @@ async fn get_settings(State(state): State>) -> impl IntoResponse { subtitle_format: subs.format, subtitle_langs: subs.langs, youtube_player_clients: player_clients_out.clone(), + convert_mode: convert.mode.clone(), + convert_crf: convert.crf, + convert_preset: convert.preset.clone(), + convert_audio_format: convert.audio_format.clone(), + convert_keep_original: convert.keep_original, }) } @@ -1434,6 +1451,12 @@ async fn post_settings( cfg.subtitles.format = body.subtitle_format.trim().to_string(); cfg.subtitles.langs = body.subtitle_langs.trim().to_string(); cfg.backup.youtube_player_clients = body.youtube_player_clients.trim().to_string(); + // Global format-conversion defaults. + cfg.convert.mode = body.convert_mode.trim().to_string(); + cfg.convert.crf = body.convert_crf; + cfg.convert.preset = body.convert_preset.trim().to_string(); + cfg.convert.audio_format = body.convert_audio_format.trim().to_string(); + cfg.convert.keep_original = body.convert_keep_original; if let Err(e) = cfg.save(&state.config_path) { return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response(); @@ -1449,6 +1472,7 @@ async fn post_settings( let use_pot_provider = cfg.backup.use_pot_provider; let subs = cfg.subtitles.clone(); let player_clients_out = cfg.backup.youtube_player_clients.clone(); + let convert = cfg.convert.clone(); drop(cfg); // Apply the new concurrency limit and binary choices to the live downloader. @@ -1461,6 +1485,7 @@ async fn post_settings( dl.use_pot_provider = use_pot_provider; dl.subtitle_defaults = subs.clone(); dl.youtube_player_clients = player_clients_out.clone(); + dl.convert_defaults = convert.clone(); } if let Some(new_pwd) = &body.new_download_password { @@ -1516,6 +1541,11 @@ async fn post_settings( subtitle_format: subs.format, subtitle_langs: subs.langs, youtube_player_clients: player_clients_out.clone(), + convert_mode: convert.mode.clone(), + convert_crf: convert.crf, + convert_preset: convert.preset.clone(), + convert_audio_format: convert.audio_format.clone(), + convert_keep_original: convert.keep_original, }).into_response() } @@ -2497,6 +2527,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { ); downloader.subtitle_defaults = config.subtitles.clone(); downloader.youtube_player_clients = config.backup.youtube_player_clients.clone(); + downloader.convert_defaults = config.convert.clone(); let music_root = downloader.music_root(); let _ = std::fs::create_dir_all(&music_root); let transcode = AtomicBool::new(config.web.transcode); diff --git a/src/web_ui/index.html b/src/web_ui/index.html index bf663ea..5a404f6 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -961,6 +961,11 @@ async function openChannelOptions(idx){
Per-channel --extractor-args youtube:player_client override. If this channel keeps hitting captchas, try tv,mweb (least bot-checked).
+
+
+
Post-download ffmpeg conversion for this channel. CRF / preset / audio-format come from the global Format-conversion setting.