Fix real bugs found by an independent Opus review of the transcode feature
Requested and applied a full review of the AV1 transcode implementation. Findings and fixes: - Season-pack import bypassed upgrade_locked entirely: a season pack scored higher than a locally-transcoded file's stale release score would silently overwrite it. Fixed in import_season_pack_file (and mirrored into import_one for defense-in-depth) to check upgrade_locked before the score comparison, not after. - The post-import enqueue hook only checked anime, silently skipping the HDR/2160p exclusions find_backlog_candidates applies to the backfill -- a freshly-grabbed HDR file would have gone through the unverified HDR-via-VAAPI path. Centralized all eligibility rules (anime, already-av1, HDR, height) into transcode::should_enqueue, used by both import_one and the newly-added season-pack enqueue hook (season packs previously had no transcode hook at all, despite being the actual headline use case -- 100GB+ season packs). - find_backlog_candidates: a NULL-height row was included as a candidate but could never actually be claimed (claim_pending_jobs requires non-null height), permanently stuck pending. Fixed the WHERE clause. - encode_and_verify now probes the real input file first and skips the encode entirely if it's already AV1 -- closes a narrow crash window where a rename-succeeded-but-DB-update-failed job would otherwise re-encode an already-transcoded file on retry. - finalize_job's success path could leak a verified temp file and strand a job in 'running' forever if a filesystem operation failed partway through; now wrapped so any failure there cleans up and marks the job failed like every other error path. - reset_orphaned_running_jobs now also sweeps each reset job's leftover temp file (job-id-scoped paths, so this lookup is unambiguous) rather than leaking them on a disk that's usually already tight on space. - Added a unique index preventing two active jobs for the same file ever existing at once -- closes the remaining gap in last commit's concurrency fix (a daemon restart's reset could otherwise race a still-alive transcode-library backfill onto the same file). - Made the VAAPI rate-control mode explicit (VBR) instead of driver-inferred. - The daemon's transcode ticker awaited each cycle inline in the tokio::select! loop, blocking every other cycle (import, search, upgrade, reconcile) for the full multi-minute duration of an encode. Now spawns each cycle detached with a try-lock guard so overlapping ticks skip cleanly rather than stacking (the concurrency cap from the previous commit makes this safe). Two items reviewed and deliberately left as documented, not fixed: mp4 sources with mov_text subtitles will fail the encode cleanly (no data loss, just no space saved) since Matroska can't hold that codec via stream copy -- fixing this needs per-stream codec probing that wasn't safe to add untested at this hour. Renaming an mp4 source's extension to .mkv after transcoding is cosmetic (Jellyfin content-sniffs fine) and left alone.
This commit is contained in:
parent
576aad3bfe
commit
66d323b7f7
5 changed files with 395 additions and 65 deletions
|
|
@ -358,6 +358,16 @@ async fn background_loop(
|
|||
let mut upgrade_ticker = tokio::time::interval(std::time::Duration::from_secs(
|
||||
config.sources.upgrade_poll_interval_secs,
|
||||
));
|
||||
// Guards against the transcode ticker itself blocking every other cycle
|
||||
// (import, search, upgrade, reconcile) for the full multi-minute
|
||||
// duration of an encode — the ticker below spawns each cycle detached
|
||||
// rather than awaiting it inline, and this is what stops two spawned
|
||||
// cycles from running at once if one is still going when the next tick
|
||||
// fires (a real possibility: files easily take longer to encode than
|
||||
// `poll_interval_secs`). `claim_pending_jobs`'s own concurrency cap
|
||||
// already makes overlap *safe*; this just keeps it from happening
|
||||
// pointlessly.
|
||||
let transcode_busy = std::sync::Arc::new(tokio::sync::Mutex::new(()));
|
||||
let mut transcode_ticker = tokio::time::interval(std::time::Duration::from_secs(
|
||||
config.transcode.poll_interval_secs,
|
||||
));
|
||||
|
|
@ -420,7 +430,7 @@ async fn background_loop(
|
|||
_ = import_ticker.tick() => {
|
||||
let result = {
|
||||
let conn = conn.lock().await;
|
||||
importer::run_import_cycle(&conn, &qbit, jellyfin.as_ref(), &config.qbit.category, &config.qbit.container_downloads_path, &config.qbit.host_downloads_path, config.transcode.enabled).await
|
||||
importer::run_import_cycle(&conn, &qbit, jellyfin.as_ref(), &config.qbit.category, &config.qbit.container_downloads_path, &config.qbit.host_downloads_path, config.transcode.enabled.then_some(&config.transcode)).await
|
||||
};
|
||||
if let (Ok(stats), Some(n)) = (&result, ¬ifier) {
|
||||
if stats.failed > 0 {
|
||||
|
|
@ -553,18 +563,33 @@ async fn background_loop(
|
|||
if !config.transcode.enabled {
|
||||
continue;
|
||||
}
|
||||
let result = transcode::run_cycle(conn.clone(), config.transcode.clone(), jellyfin.as_ref()).await;
|
||||
let record = match &result {
|
||||
Ok(stats) => {
|
||||
info!(?stats, "transcode cycle complete");
|
||||
api::CycleRecord { at: chrono::Utc::now(), ok: true, detail: format!("{stats:?}") }
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "transcode cycle failed");
|
||||
api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() }
|
||||
}
|
||||
let Ok(busy_permit) = transcode_busy.clone().try_lock_owned() else {
|
||||
// A previous cycle is still running (this file's
|
||||
// encode took longer than one poll interval) — skip
|
||||
// this tick rather than spawning a second overlapping
|
||||
// one; the still-running cycle will pick up any newly
|
||||
// pending jobs on its own next iteration anyway.
|
||||
continue;
|
||||
};
|
||||
cycle_status.lock().expect("cycle_status poisoned").last_transcode = Some(record);
|
||||
let conn = conn.clone();
|
||||
let cfg = config.transcode.clone();
|
||||
let jellyfin = jellyfin.clone();
|
||||
let cycle_status = cycle_status.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = busy_permit;
|
||||
let result = transcode::run_cycle(conn, cfg, jellyfin.as_ref()).await;
|
||||
let record = match &result {
|
||||
Ok(stats) => {
|
||||
info!(?stats, "transcode cycle complete");
|
||||
api::CycleRecord { at: chrono::Utc::now(), ok: true, detail: format!("{stats:?}") }
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "transcode cycle failed");
|
||||
api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() }
|
||||
}
|
||||
};
|
||||
cycle_status.lock().expect("cycle_status poisoned").last_transcode = Some(record);
|
||||
});
|
||||
}
|
||||
_ = reconcile_ticker.tick() => {
|
||||
let result = {
|
||||
|
|
@ -879,7 +904,7 @@ async fn debug_import_cycle(config: &Config) -> Result<()> {
|
|||
&config.qbit.category,
|
||||
&config.qbit.container_downloads_path,
|
||||
&config.qbit.host_downloads_path,
|
||||
config.transcode.enabled,
|
||||
config.transcode.enabled.then_some(&config.transcode),
|
||||
)
|
||||
.await?;
|
||||
println!("{stats:?}");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue