Format conversion pipeline (Tartube parity 1.7)

Post-download ffmpeg pass with three modes, global + per-channel.
Closes the last real Tartube parity gap.

## Modes
- remux-mp4: fast container change mkv→mp4 (stream-copy video, aac audio)
- h264-mp4: re-encode to H.264/AAC at a configurable CRF + x264 preset
- audio: extract to mp3 / m4a / opus / flac

## Design (separate ffmpeg pass)
The main download adds `--print after_move:CONVERT_PATH:%(filepath)s`
when conversion is active. poll() scans freshly-Done download jobs for
those lines and enqueues a distinct ffmpeg transcode Job per finished
file — so the UI shows transcoding as its own phase. On success the
source is renamed to <name>.original.<ext> (keep_original) or deleted;
the converted file lands next to it. Transcodes don't auto-retry and
don't themselves convert (no infinite loop).

## Config
New [convert] section (mode / crf / preset / audio_format /
keep_original) + per-channel DownloadOptions.convert_mode override
(None = defer to global, "off" = force-disable for that channel).
ConvertSpec::resolve merges them; CRF 0→23, empty preset→medium, empty
audio→mp3 defaults.

## UI
Desktop + web: a "Format conversion (global defaults)" Settings section
with mode-dependent rows (CRF/preset for h264, format for audio, keep-
original toggle), and a per-channel Convert dropdown in the channel-
options dialog.

Verified: all three ffmpeg modes produce valid output on a real library
file (remux instant, h264 CRF23 ~12s for a 3.4min clip, mp3 extraction);
settings round-trip (global → config.toml [convert] → echo; per-channel
override saves + reads back). 4 resolver tests; 95 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-03 23:20:06 -07:00
parent b58d393d98
commit fd9063b5cb
6 changed files with 534 additions and 9 deletions

View file

@ -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<String> on save.
convert_idx: usize,
extra_args: String, // one per line
}
@ -245,6 +248,27 @@ fn idx_to_tri(i: usize) -> Option<bool> {
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<String>) -> 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<String> {
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<crate::tray::TrayHandle>) -> 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("051, 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 <name>.original.<ext>. 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

View file

@ -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` (051; 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 `<stem>.original.<ext>`). 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(),
}
}
}

View file

@ -92,6 +92,14 @@ pub struct DownloadOptions {
#[serde(default)]
pub youtube_player_clients: Option<String>,
/// 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<String>,
/// 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`.

View file

@ -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<ConvertSpec>,
/// 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<Msg>,
}
@ -158,6 +165,46 @@ struct RetrySpec {
opts: Option<DownloadOptions>,
}
/// 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<ConvertSpec> {
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<RetrySpec>,
/// Attempt number this command represents (0 = first try).
retry_count: u8,
/// Post-download convert spec for this job (None = no conversion).
convert_spec: Option<ConvertSpec>,
}
/// 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<ConvertSpec>,
}
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<RetrySpec>, retry_count: u8) {
fn spawn_job(&mut self, mut cmd: Command, url: String, label: String, retry_spec: Option<RetrySpec>, retry_count: u8, convert_on_finish: Option<ConvertSpec>) {
// 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
/// `<stem>.original.<ext>` (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<String>) = 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(

View file

@ -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<Arc<WebState>>) -> 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<Arc<WebState>>) -> 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);

View file

@ -961,6 +961,11 @@ async function openChannelOptions(idx){
<label>YouTube player clients (blank = global)</label>
<input type="text" id="opt-player-clients" value="${esc(opts.youtube_player_clients||'')}" placeholder="e.g. tv,mweb" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-hint" style="margin-bottom:4px">Per-channel <code>--extractor-args youtube:player_client</code> override. If this channel keeps hitting captchas, try <code>tv,mweb</code> (least bot-checked).</div>
<div class="settings-row"><label>Convert (blank = global)</label>
<select id="opt-convert" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
${[['','Default (global)'],['off','Off'],['remux-mp4','Remux → mp4'],['h264-mp4','H.264 mp4'],['audio','Audio']].map(([v,l])=>`<option value="${v}"${(opts.convert_mode||'')===v?' selected':''}>${l}</option>`).join('')}
</select></div>
<div class="settings-hint" style="margin-bottom:4px">Post-download ffmpeg conversion for this channel. CRF / preset / audio-format come from the global Format-conversion setting.</div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Extra yt-dlp args (one per line)</label>
<textarea id="opt-extra" rows="3" placeholder="--no-mtime
@ -995,6 +1000,7 @@ function readChannelOptionsForm(){
subtitles_embed:triValue('opt-subs-embed'),
subtitle_format:strOrNull('opt-subs-format'),
youtube_player_clients:strOrNull('opt-player-clients'),
convert_mode:strOrNull('opt-convert'),
extra_args:extra,
};
}
@ -1508,6 +1514,38 @@ async function openSettings(){
<div class="settings-hint" style="margin-bottom:4px">--convert-subs. srt is the most player/Plex-compatible.</div>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Format conversion (global defaults)</div>
<div class="settings-hint" style="margin-bottom:6px">Runs ffmpeg after each download. Individual channels can override the mode in their options. Requires ffmpeg on the server.</div>
<div class="settings-row">
<label for="cf-convert-mode">Mode</label>
<select id="cf-convert-mode" onchange="updateConvertRows()" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
<option value=""${!cur.convert_mode?' selected':''}>Off</option>
<option value="remux-mp4"${cur.convert_mode==='remux-mp4'?' selected':''}>Remux → mp4 (no re-encode)</option>
<option value="h264-mp4"${cur.convert_mode==='h264-mp4'?' selected':''}>Re-encode → H.264 mp4</option>
<option value="audio"${cur.convert_mode==='audio'?' selected':''}>Extract audio</option>
</select>
</div>
<div id="cf-convert-crf-row" class="settings-row" style="display:${cur.convert_mode==='h264-mp4'?'flex':'none'}">
<label for="cf-convert-crf">CRF (051, lower=better)</label>
<input type="number" id="cf-convert-crf" value="${cur.convert_crf||23}" min="0" max="51" style="width:70px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
</div>
<div id="cf-convert-preset-row" class="settings-row" style="display:${cur.convert_mode==='h264-mp4'?'flex':'none'}">
<label for="cf-convert-preset">x264 preset</label>
<select id="cf-convert-preset" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
${['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower','veryslow'].map(p=>`<option value="${p}"${(cur.convert_preset||'medium')===p?' selected':''}>${p}</option>`).join('')}
</select>
</div>
<div id="cf-convert-audio-row" class="settings-row" style="display:${cur.convert_mode==='audio'?'flex':'none'}">
<label for="cf-convert-audio">Audio format</label>
<select id="cf-convert-audio" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
${['mp3','m4a','opus','flac'].map(f=>`<option value="${f}"${(cur.convert_audio_format||'mp3')===f?' selected':''}>${f}</option>`).join('')}
</select>
</div>
<div id="cf-convert-keep-row" class="settings-row" style="display:${cur.convert_mode?'flex':'none'}">
<label for="cf-convert-keep">Keep original alongside converted</label>
<input type="checkbox" id="cf-convert-keep" ${cur.convert_keep_original?'checked':''}>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Plex</div>
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Library path</label>
@ -1662,6 +1700,15 @@ async function runScheduler(btn){
}catch(e){st.textContent='Error: '+e.message}
finally{btn.disabled=false}
}
// Show only the convert sub-rows relevant to the selected mode.
function updateConvertRows(){
const mode=document.getElementById('cf-convert-mode')?.value||'';
const show=(id,on)=>{const el=document.getElementById(id);if(el)el.style.display=on?'flex':'none'};
show('cf-convert-crf-row',mode==='h264-mp4');
show('cf-convert-preset-row',mode==='h264-mp4');
show('cf-convert-audio-row',mode==='audio');
show('cf-convert-keep-row',!!mode);
}
async function saveSettings(btn){
const transcode=document.getElementById('cf-transcode').checked;
const bindMode=document.getElementById('cf-bind')?.value;
@ -1680,7 +1727,12 @@ async function saveSettings(btn){
const subsLangs=document.getElementById('cf-subs-langs')?.value||'';
const subsFormat=document.getElementById('cf-subs-format')?.value||'';
const playerClients=document.getElementById('cf-player-clients')?.value||'';
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients};
const convMode=document.getElementById('cf-convert-mode')?.value||'';
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
const convKeep=document.getElementById('cf-convert-keep')?.checked||false;
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep};
if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl;