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.
71 lines
2.6 KiB
Rust
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())
|
|
}
|
|
}
|