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

@ -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(),
}
}
}