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:
parent
b58d393d98
commit
fd9063b5cb
6 changed files with 534 additions and 9 deletions
112
src/app.rs
112
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<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("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 <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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue