breadarr/breadarrd/src/jellyfin.rs
Breadway 66d323b7f7 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.
2026-07-25 00:38:51 +08:00

71 lines
2.6 KiB
Rust

use anyhow::{bail, Context, Result};
#[derive(Clone)]
pub struct JellyfinClient {
base_url: String,
api_key: String,
client: reqwest::Client,
}
impl JellyfinClient {
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
api_key: api_key.into(),
// No total timeout is reqwest's default — fine for a one-shot
// debug command, but the daemon's background loop holds the DB
// mutex across this call, so a stalled connection here would
// hang the whole daemon indefinitely.
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client build"),
}
}
pub async fn refresh_library(&self) -> Result<()> {
let resp = self
.client
.post(format!("{}/Library/Refresh", self.base_url))
.header("X-Emby-Token", &self.api_key)
.send()
.await
.context("jellyfin library refresh request failed")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("jellyfin library refresh failed: status={status} body={body:?}");
}
Ok(())
}
/// Counts sessions Jellyfin is actively transcoding for right now (as
/// opposed to direct-play/direct-stream, which cost the GPU nothing) —
/// used to throttle the AV1 batch-transcode worker back so it doesn't
/// contend with a real viewer for the same encode/decode engines.
/// `TranscodingInfo` is only present on a session object while that
/// session is actually transcoding.
pub async fn active_transcode_sessions(&self) -> Result<usize> {
let resp = self
.client
.get(format!("{}/Sessions", self.base_url))
.header("X-Emby-Token", &self.api_key)
.send()
.await
.context("jellyfin sessions request failed")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("jellyfin sessions request failed: status={status} body={body:?}");
}
let sessions: Vec<serde_json::Value> =
resp.json().await.context("failed to parse jellyfin sessions response")?;
Ok(sessions
.iter()
.filter(|s| !s["TranscodingInfo"].is_null())
.count())
}
}