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
|
||||
|
|
|
|||
|
|
@ -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 `<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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
31
src/web.rs
31
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<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);
|
||||
|
|
|
|||
|
|
@ -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 (0–51, 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue