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

@ -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;