3790 lines
146 KiB
Rust
3790 lines
146 KiB
Rust
pub mod ffprobe;
|
|
pub mod mkv;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use rusqlite::{params, Connection, OptionalExtension};
|
|
|
|
use crate::jellyfin::JellyfinClient;
|
|
use crate::qbit::QbitClient;
|
|
|
|
pub(crate) const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "avi", "mov"];
|
|
|
|
/// Height/width floor for `flag_under_quality`, matching the 1080p bar
|
|
/// already established by the scoring gate's own "reject a sub-1080p
|
|
/// release when a better alternative exists" rule (`scoring/gate.rs`),
|
|
/// applied here to the actual decoded file rather than a claimed
|
|
/// title-text resolution. Not configurable, unlike `QualityProfile`'s
|
|
/// per-axis weights (see `QualityProfile::with_weights_override`): this is
|
|
/// a post-import ground-truth sanity floor, not a scoring preference, so it
|
|
/// stays a constant rather than a config knob.
|
|
///
|
|
/// **Both** width and height must be below their own floor before a file
|
|
/// is flagged — height alone isn't a valid "1080p or not" test, since a
|
|
/// wider-than-16:9 master (2.00:1 is common for prestige/streaming shows;
|
|
/// e.g. Apple TV+'s "For All Mankind" ships at 1920x960) legitimately has
|
|
/// full 1920px width with height well under 1080 purely from the aspect
|
|
/// ratio, not from being a worse encode. Verified live: 49 real episodes
|
|
/// at 1920x960 were false-positive-flagged under a height-only check
|
|
/// before this was caught. Width is the aspect-ratio-invariant half of the
|
|
/// pair, so requiring *both* dimensions to read low is what actually
|
|
/// distinguishes "genuinely low resolution" from "correctly cropped."
|
|
const UNDER_QUALITY_HEIGHT: i64 = 1080;
|
|
const UNDER_QUALITY_WIDTH: i64 = 1920;
|
|
|
|
enum PendingGrab {
|
|
Episode {
|
|
release_id: i64,
|
|
episode_id: i64,
|
|
media_item_id: i64,
|
|
torrent_hash: String,
|
|
series_title: String,
|
|
season_number: u32,
|
|
episode_number: u32,
|
|
episode_title: Option<String>,
|
|
root_folder: String,
|
|
},
|
|
Movie {
|
|
release_id: i64,
|
|
media_item_id: i64,
|
|
torrent_hash: String,
|
|
title: String,
|
|
year: Option<i64>,
|
|
root_folder: String,
|
|
},
|
|
/// A season-pack/batch torrent: one release row, but potentially many
|
|
/// files inside mapping to many episodes — see `import_season_pack`.
|
|
/// `episode_id` stays absent on the release row (`season_number`
|
|
/// disambiguates instead, same convention as everywhere else this
|
|
/// column is used).
|
|
SeasonPack {
|
|
release_id: i64,
|
|
media_item_id: i64,
|
|
torrent_hash: String,
|
|
series_title: String,
|
|
season_number: u32,
|
|
root_folder: String,
|
|
},
|
|
}
|
|
|
|
impl PendingGrab {
|
|
fn release_id(&self) -> i64 {
|
|
match self {
|
|
PendingGrab::Episode { release_id, .. }
|
|
| PendingGrab::Movie { release_id, .. }
|
|
| PendingGrab::SeasonPack { release_id, .. } => *release_id,
|
|
}
|
|
}
|
|
|
|
fn media_item_id(&self) -> i64 {
|
|
match self {
|
|
PendingGrab::Episode { media_item_id, .. }
|
|
| PendingGrab::Movie { media_item_id, .. }
|
|
| PendingGrab::SeasonPack { media_item_id, .. } => *media_item_id,
|
|
}
|
|
}
|
|
|
|
fn torrent_hash(&self) -> &str {
|
|
match self {
|
|
PendingGrab::Episode { torrent_hash, .. }
|
|
| PendingGrab::Movie { torrent_hash, .. }
|
|
| PendingGrab::SeasonPack { torrent_hash, .. } => torrent_hash,
|
|
}
|
|
}
|
|
|
|
fn episode_id(&self) -> Option<i64> {
|
|
match self {
|
|
PendingGrab::Episode { episode_id, .. } => Some(*episode_id),
|
|
PendingGrab::Movie { .. } | PendingGrab::SeasonPack { .. } => None,
|
|
}
|
|
}
|
|
|
|
fn root_folder(&self) -> &str {
|
|
match self {
|
|
PendingGrab::Episode { root_folder, .. }
|
|
| PendingGrab::Movie { root_folder, .. }
|
|
| PendingGrab::SeasonPack { root_folder, .. } => root_folder,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Three separate queries (rather than one `LEFT JOIN episode`) because a
|
|
/// movie/season-pack release's `episode_id` is NULL, which would otherwise
|
|
/// force every episode-only column to be handled as `Option` for no
|
|
/// benefit — matches the movie/episode split already used elsewhere
|
|
/// (`process_item`, `enumerate_search_targets`).
|
|
fn fetch_pending_grabs(conn: &Connection) -> Result<Vec<PendingGrab>> {
|
|
let mut out = Vec::new();
|
|
|
|
let mut ep_stmt = conn.prepare(
|
|
"SELECT r.id, r.episode_id, r.media_item_id, r.torrent_hash, m.title, e.season_number, e.episode_number, e.title, m.root_folder
|
|
FROM release r
|
|
JOIN episode e ON e.id = r.episode_id
|
|
JOIN media_item m ON m.id = r.media_item_id
|
|
WHERE r.status = 'grabbed' AND r.torrent_hash IS NOT NULL",
|
|
)?;
|
|
let ep_rows = ep_stmt.query_map([], |row| {
|
|
Ok(PendingGrab::Episode {
|
|
release_id: row.get(0)?,
|
|
episode_id: row.get(1)?,
|
|
media_item_id: row.get(2)?,
|
|
torrent_hash: row.get(3)?,
|
|
series_title: row.get(4)?,
|
|
season_number: row.get(5)?,
|
|
episode_number: row.get(6)?,
|
|
episode_title: row.get(7)?,
|
|
root_folder: row.get(8)?,
|
|
})
|
|
})?;
|
|
out.extend(ep_rows.collect::<rusqlite::Result<Vec<_>>>()?);
|
|
|
|
let mut movie_stmt = conn.prepare(
|
|
"SELECT r.id, r.media_item_id, r.torrent_hash, m.title, m.year, m.root_folder
|
|
FROM release r
|
|
JOIN media_item m ON m.id = r.media_item_id
|
|
WHERE r.status = 'grabbed' AND r.torrent_hash IS NOT NULL AND r.episode_id IS NULL AND r.season_number IS NULL",
|
|
)?;
|
|
let movie_rows = movie_stmt.query_map([], |row| {
|
|
Ok(PendingGrab::Movie {
|
|
release_id: row.get(0)?,
|
|
media_item_id: row.get(1)?,
|
|
torrent_hash: row.get(2)?,
|
|
title: row.get(3)?,
|
|
year: row.get(4)?,
|
|
root_folder: row.get(5)?,
|
|
})
|
|
})?;
|
|
out.extend(movie_rows.collect::<rusqlite::Result<Vec<_>>>()?);
|
|
|
|
let mut pack_stmt = conn.prepare(
|
|
"SELECT r.id, r.media_item_id, r.torrent_hash, m.title, r.season_number, m.root_folder
|
|
FROM release r
|
|
JOIN media_item m ON m.id = r.media_item_id
|
|
WHERE r.status = 'grabbed' AND r.torrent_hash IS NOT NULL AND r.episode_id IS NULL AND r.season_number IS NOT NULL",
|
|
)?;
|
|
let pack_rows = pack_stmt.query_map([], |row| {
|
|
Ok(PendingGrab::SeasonPack {
|
|
release_id: row.get(0)?,
|
|
media_item_id: row.get(1)?,
|
|
torrent_hash: row.get(2)?,
|
|
series_title: row.get(3)?,
|
|
season_number: row.get(4)?,
|
|
root_folder: row.get(5)?,
|
|
})
|
|
})?;
|
|
out.extend(pack_rows.collect::<rusqlite::Result<Vec<_>>>()?);
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
fn locate_video_file(content_path: &Path) -> Result<PathBuf> {
|
|
if content_path.is_file() {
|
|
return Ok(content_path.to_path_buf());
|
|
}
|
|
largest_video_file(content_path)
|
|
}
|
|
|
|
pub(crate) fn largest_video_file(dir: &Path) -> Result<PathBuf> {
|
|
let mut best: Option<(PathBuf, u64)> = None;
|
|
for entry in walk_files(dir)? {
|
|
let ext = entry
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
if !VIDEO_EXTS.contains(&ext.as_str()) {
|
|
continue;
|
|
}
|
|
let size = std::fs::metadata(&entry)?.len();
|
|
if best.as_ref().is_none_or(|(_, s)| size > *s) {
|
|
best = Some((entry, size));
|
|
}
|
|
}
|
|
best.map(|(p, _)| p)
|
|
.with_context(|| format!("no video file found under {}", dir.display()))
|
|
}
|
|
|
|
pub(crate) fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
|
|
let mut out = Vec::new();
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let path = entry?.path();
|
|
if path.is_dir() {
|
|
out.extend(walk_files(&path)?);
|
|
} else {
|
|
out.push(path);
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// TV episode files live under a `Season NN` subfolder of the show's
|
|
/// `root_folder`, matching the layout Jellyfin/Sonarr/library-scan already
|
|
/// use — episodes imported flat into the show root previously left new
|
|
/// grabs sitting alongside, rather than inside, the season structure that
|
|
/// pre-existing library files were organized into.
|
|
pub(crate) fn season_dir(root_folder: &str, season_number: u32) -> PathBuf {
|
|
Path::new(root_folder).join(format!("Season {season_number:02}"))
|
|
}
|
|
|
|
pub(crate) fn deterministic_filename(
|
|
series_title: &str,
|
|
season: u32,
|
|
episode: u32,
|
|
episode_title: Option<&str>,
|
|
ext: &str,
|
|
) -> String {
|
|
let series = sanitize(series_title);
|
|
match episode_title.filter(|t| !t.is_empty()) {
|
|
Some(t) => format!(
|
|
"{series} - S{season:02}E{episode:02} - {}.{ext}",
|
|
sanitize(t)
|
|
),
|
|
None => format!("{series} - S{season:02}E{episode:02}.{ext}"),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn deterministic_movie_filename(title: &str, year: Option<i64>, ext: &str) -> String {
|
|
let title = sanitize(title);
|
|
match year {
|
|
Some(y) => format!("{title} ({y}).{ext}"),
|
|
None => format!("{title}.{ext}"),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn sanitize(s: &str) -> String {
|
|
s.chars()
|
|
.map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c })
|
|
.collect()
|
|
}
|
|
|
|
/// True when there isn't enough free space at `dir` to hold `needed_bytes`.
|
|
/// Checked before every import — a library volume running out of space is a
|
|
/// real failure mode, and a plain `copy` with no such guard leaves an ENOSPC
|
|
/// partway through as a truncated file sitting at the final destination
|
|
/// path. `blocks_available` (rather than `blocks_free`) matches what `df`
|
|
/// reports, since it excludes space reserved for the superuser.
|
|
fn insufficient_space(dir: &Path, needed_bytes: u64) -> Result<bool> {
|
|
let stat = nix::sys::statvfs::statvfs(dir)
|
|
.with_context(|| format!("failed to stat filesystem for {}", dir.display()))?;
|
|
let available = stat.blocks_available() * stat.fragment_size();
|
|
Ok(available < needed_bytes)
|
|
}
|
|
|
|
/// Hardlinks into the destination (same filesystem, effectively free) and
|
|
/// falls back to a plain copy cross-filesystem — deliberately leaves `src`
|
|
/// untouched either way. The previous `rename`-then-delete behavior yanked
|
|
/// the file out of qBittorrent's payload directory on every import, leaving
|
|
/// qBittorrent holding a torrent whose data had vanished (an errored
|
|
/// "missing files" state, seeding stopped instantly). A hardlink costs
|
|
/// nothing extra on disk and lets qBittorrent keep seeding after import;
|
|
/// even the copy fallback preserves seeding, just at the cost of double
|
|
/// disk usage for that one file.
|
|
///
|
|
/// The copy path writes to a `.part` sibling of `dest` and only renames it
|
|
/// into place once the copy is complete (a same-filesystem rename, so
|
|
/// atomic) — a crash mid-copy leaves an orphaned `.part` file rather than a
|
|
/// truncated file at `dest`, so a retried import can't ever double-count a
|
|
/// half-written file as already present.
|
|
fn link_or_copy_file(src: &Path, dest: &Path) -> Result<()> {
|
|
if std::fs::hard_link(src, dest).is_ok() {
|
|
return Ok(());
|
|
}
|
|
copy_via_temp_file(src, dest)
|
|
}
|
|
|
|
/// The copy fallback's actual mechanics, split out so it's directly
|
|
/// testable without needing a real cross-filesystem boundary to force
|
|
/// `hard_link` to fail.
|
|
fn copy_via_temp_file(src: &Path, dest: &Path) -> Result<()> {
|
|
let tmp = PathBuf::from(format!("{}.part", dest.display()));
|
|
std::fs::copy(src, &tmp)
|
|
.with_context(|| format!("failed to copy {} to {}", src.display(), tmp.display()))?;
|
|
std::fs::rename(&tmp, dest).with_context(|| {
|
|
format!(
|
|
"failed to move completed copy into place at {}",
|
|
dest.display()
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq)]
|
|
pub struct ImportStats {
|
|
pub imported: usize,
|
|
pub remuxed: usize,
|
|
pub skipped_incomplete: usize,
|
|
pub errors: usize,
|
|
pub failed: usize,
|
|
/// Freshly-imported files where the post-import ffprobe found a
|
|
/// ground-truth problem the title text didn't reveal (under-quality
|
|
/// despite a claimed-good resolution, or unreadable) — see
|
|
/// `probe_indicates_import_problem`.
|
|
pub quality_flagged: usize,
|
|
}
|
|
|
|
/// How long a grab can sit with its progress unchanged before it's declared
|
|
/// stalled (dead seeders, a torrent that will never complete) and released
|
|
/// back into the search pool.
|
|
const STALL_THRESHOLD_HOURS: f64 = 72.0;
|
|
|
|
/// How long a grab's torrent hash can be absent from qBittorrent's list
|
|
/// before it's treated as genuinely gone (manually removed, category
|
|
/// changed) rather than just not-yet-indexed since the grab.
|
|
const MISSING_GRACE_MINUTES: f64 = 15.0;
|
|
|
|
/// Advances the release's progress watermark, but only when progress has
|
|
/// genuinely increased — `last_progress_at` staying still while progress
|
|
/// stays still is exactly the signal `grab_is_stalled` looks for.
|
|
fn update_grab_progress(conn: &Connection, release_id: i64, progress: f64) -> Result<()> {
|
|
let prev: Option<f64> = conn.query_row(
|
|
"SELECT last_seen_progress FROM release WHERE id = ?1",
|
|
params![release_id],
|
|
|row| row.get(0),
|
|
)?;
|
|
if prev.is_none_or(|p| progress > p) {
|
|
conn.execute(
|
|
"UPDATE release SET last_seen_progress = ?2, last_progress_at = datetime('now') WHERE id = ?1",
|
|
params![release_id, progress],
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// True once a grab has gone `STALL_THRESHOLD_HOURS` without its progress
|
|
/// advancing. Falls back to `grabbed_at` when progress has never been
|
|
/// observed advancing at all (stuck at the same percentage, including 0%,
|
|
/// since the moment it was grabbed).
|
|
fn grab_is_stalled(conn: &Connection, release_id: i64) -> Result<bool> {
|
|
conn.query_row(
|
|
"SELECT (julianday('now') - julianday(COALESCE(last_progress_at, grabbed_at))) * 24.0 > ?2
|
|
FROM release WHERE id = ?1",
|
|
params![release_id, STALL_THRESHOLD_HOURS],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// True once a grabbed release's torrent hash has been missing from qBit's
|
|
/// torrent list for longer than a short grace period — long enough to rule
|
|
/// out qBit simply not having indexed a just-added torrent yet, short
|
|
/// enough that a torrent genuinely removed by hand doesn't block re-search
|
|
/// for days.
|
|
fn grab_missing_past_grace(conn: &Connection, release_id: i64) -> Result<bool> {
|
|
conn.query_row(
|
|
"SELECT (julianday('now') - julianday(grabbed_at)) * 24.0 * 60.0 > ?2
|
|
FROM release WHERE id = ?1",
|
|
params![release_id, MISSING_GRACE_MINUTES],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// How many consecutive `import_one` failures a completed torrent gets
|
|
/// before it's given up on. A few retries absorb transient issues (a
|
|
/// filesystem hiccup, a momentarily-unavailable mount); beyond that, the
|
|
/// same error every cycle forever means it's not going to fix itself, and
|
|
/// leaving `status = 'grabbed'` blocks the episode/movie from ever being
|
|
/// re-searched via a different release.
|
|
const MAX_IMPORT_ERRORS: i64 = 5;
|
|
|
|
/// Increments the release's import-error counter and returns the new
|
|
/// total, so the caller can decide whether it's crossed `MAX_IMPORT_ERRORS`.
|
|
fn record_import_error(conn: &Connection, release_id: i64) -> Result<i64> {
|
|
conn.execute(
|
|
"UPDATE release SET import_error_count = import_error_count + 1 WHERE id = ?1",
|
|
params![release_id],
|
|
)?;
|
|
conn.query_row(
|
|
"SELECT import_error_count FROM release WHERE id = ?1",
|
|
params![release_id],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// Marks a stalled or missing grab as failed. `'failed'` isn't in the
|
|
/// `('grabbed','downloading')` in-flight check used by
|
|
/// `enumerate_search_targets`/`movie_needs_grab`, so the episode or movie
|
|
/// becomes searchable again on the very next cycle — this is the fallback-
|
|
/// into-the-search-pool step, not just bookkeeping.
|
|
fn fail_grab(conn: &Connection, grab: &PendingGrab, reason: &str) -> Result<()> {
|
|
conn.execute(
|
|
"UPDATE release SET status = 'failed' WHERE id = ?1",
|
|
params![grab.release_id()],
|
|
)?;
|
|
crate::db::record_event(
|
|
conn,
|
|
grab.media_item_id(),
|
|
grab.episode_id(),
|
|
"failed",
|
|
reason,
|
|
)?;
|
|
tracing::warn!(
|
|
release_id = grab.release_id(),
|
|
media_item_id = grab.media_item_id(),
|
|
reason,
|
|
"grab failed, releasing back to the search pool"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Result of a `reconcile_missing_files` pass.
|
|
#[derive(Debug, Default, PartialEq)]
|
|
pub struct ReconcileOutcome {
|
|
/// Rows whose file had simply moved (e.g. a show folder renamed since
|
|
/// the row was written) — found by basename under the owning
|
|
/// `root_folder` and repaired in place rather than cleared.
|
|
pub repaired: usize,
|
|
/// Rows genuinely gone: not at their stored path, and no file with the
|
|
/// same name found anywhere under `root_folder` either.
|
|
pub cleared: usize,
|
|
/// True when the pass found more "genuinely gone" candidates than
|
|
/// `RECONCILE_MAX_CLEAR_FRACTION` allows and refused to clear anything —
|
|
/// see its doc comment for why.
|
|
pub aborted: bool,
|
|
}
|
|
|
|
/// Refuse to clear more than this fraction of all tracked files in a single
|
|
/// pass. A healthy library loses files one or two at a time; a mount going
|
|
/// offline, a bulk rename that outpaces the basename-repair fallback below,
|
|
/// or a bug can instead make *every* row look gone at once — exactly the
|
|
/// shape of failure that isn't self-evident from any single row, only from
|
|
/// the batch as a whole.
|
|
const RECONCILE_MAX_CLEAR_FRACTION: f64 = 0.10;
|
|
|
|
/// ...but always allow at least this many, so a small library (where 10% is
|
|
/// less than one file) still self-heals from genuinely missing files.
|
|
const RECONCILE_MIN_CLEAR_FLOOR: usize = 10;
|
|
|
|
/// Clears `episode_file`/`has_file` state for files that no longer exist on
|
|
/// disk — a file deleted or moved by hand outside breadarr (or Jellyfin's
|
|
/// own library tools) previously left stale state forever: the episode
|
|
/// looked permanently satisfied and never got re-searched, no matter how
|
|
/// long the file had actually been gone. Runs on a slow, separate ticker
|
|
/// (disk state doesn't change on its own) — see `main.rs`.
|
|
///
|
|
/// Before treating a missing path as "gone," this looks for a file of the
|
|
/// same name (or, failing that, the same stem under a different video
|
|
/// extension — see `find_by_stem`) anywhere under the owning media item's
|
|
/// `root_folder` and repairs the stored path instead — the DB `path` column
|
|
/// is only ever written once, on import, so anything that changes a file's
|
|
/// location or name afterward (the library-normalization scan appending a
|
|
/// year to a show folder; Tdarr re-encoding a file to a different container
|
|
/// in place) silently strands every affected `episode_file.path` unless
|
|
/// something re-derives it. This is that something, and it also guards
|
|
/// against a transient mount outage (`root_folder` itself absent) being
|
|
/// misread as every file under it having vanished.
|
|
///
|
|
/// `dry_run` computes and logs what would happen without writing anything —
|
|
/// used for a one-time report against a freshly-restored database before
|
|
/// trusting this to run unattended again.
|
|
pub fn reconcile_missing_files(conn: &Connection, dry_run: bool) -> Result<ReconcileOutcome> {
|
|
// `episode_file.media_item_id` is NULL for TV rows written by the
|
|
// library scan (only `episode_id` is set there) and `episode_id` is
|
|
// NULL for movie rows — resolving the owning `root_folder` needs both
|
|
// joins, COALESCEd, or half the library silently skips repair.
|
|
let mut stmt = conn.prepare(
|
|
"SELECT ef.id, ef.episode_id, ef.path,
|
|
COALESCE(mi_direct.root_folder, mi_ep.root_folder) AS root_folder
|
|
FROM episode_file ef
|
|
LEFT JOIN media_item mi_direct ON mi_direct.id = ef.media_item_id
|
|
LEFT JOIN episode e ON e.id = ef.episode_id
|
|
LEFT JOIN media_item mi_ep ON mi_ep.id = e.media_item_id",
|
|
)?;
|
|
let rows: Vec<(i64, Option<i64>, String, Option<String>)> = stmt
|
|
.query_map([], |row| {
|
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
|
})?
|
|
.collect::<rusqlite::Result<_>>()?;
|
|
let total = rows.len();
|
|
|
|
let mut to_repair: Vec<(i64, String)> = Vec::new();
|
|
let mut to_clear: Vec<(i64, Option<i64>, String)> = Vec::new();
|
|
|
|
for (file_id, episode_id, path, root_folder) in rows {
|
|
if Path::new(&path).exists() {
|
|
continue;
|
|
}
|
|
let Some(root_folder) = root_folder else {
|
|
tracing::warn!(
|
|
file_id,
|
|
path,
|
|
"missing file has no resolvable root_folder; skipping"
|
|
);
|
|
continue;
|
|
};
|
|
if !Path::new(&root_folder).is_dir() {
|
|
// The whole show/movie folder is absent — almost certainly a
|
|
// mount that isn't up yet, not hundreds of individually deleted
|
|
// files. Never clear on this basis.
|
|
tracing::warn!(
|
|
file_id,
|
|
root_folder,
|
|
"owning root_folder is absent (mount offline?); skipping"
|
|
);
|
|
continue;
|
|
}
|
|
let stored_path = Path::new(&path);
|
|
let found = stored_path
|
|
.file_name()
|
|
.and_then(|name| find_by_basename(Path::new(&root_folder), name))
|
|
.or_else(|| {
|
|
// Tdarr (this library's AV1-transcode pipeline) re-encodes a
|
|
// file in place and can land it under a different extension
|
|
// — same stem, e.g. `Upgrade (2018).mp4` becomes `Upgrade
|
|
// (2018).mkv`, with the original removed. An exact basename
|
|
// match won't see that; falling back to a stem match against
|
|
// known video extensions catches it exactly like a folder
|
|
// rename, instead of treating a freshly-transcoded file as a
|
|
// deletion.
|
|
let stem = stored_path.file_stem()?;
|
|
find_by_stem(Path::new(&root_folder), stem)
|
|
});
|
|
match found {
|
|
Some(new_path) => to_repair.push((file_id, new_path.to_string_lossy().into_owned())),
|
|
None => to_clear.push((file_id, episode_id, path)),
|
|
}
|
|
}
|
|
|
|
let clear_limit = (((total as f64) * RECONCILE_MAX_CLEAR_FRACTION).ceil() as usize)
|
|
.max(RECONCILE_MIN_CLEAR_FLOOR);
|
|
if to_clear.len() > clear_limit {
|
|
tracing::error!(
|
|
would_clear = to_clear.len(),
|
|
would_repair = to_repair.len(),
|
|
total,
|
|
clear_limit,
|
|
"reconcile wanted to clear an anomalous number of files in one pass — refusing \
|
|
(a mount may be offline, or something changed how paths are laid out). \
|
|
No rows were changed."
|
|
);
|
|
return Ok(ReconcileOutcome {
|
|
aborted: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
if dry_run {
|
|
for (file_id, new_path) in &to_repair {
|
|
tracing::info!(file_id, new_path, "[dry-run] would repair stale path");
|
|
}
|
|
for (file_id, episode_id, path) in &to_clear {
|
|
tracing::info!(file_id, episode_id, path, "[dry-run] would clear");
|
|
}
|
|
return Ok(ReconcileOutcome {
|
|
repaired: to_repair.len(),
|
|
cleared: to_clear.len(),
|
|
aborted: false,
|
|
});
|
|
}
|
|
|
|
// One transaction so a mid-run error can't leave the library half
|
|
// repaired/half cleared.
|
|
let tx = conn.unchecked_transaction()?;
|
|
for (file_id, new_path) in &to_repair {
|
|
tx.execute(
|
|
"UPDATE episode_file SET path = ?1 WHERE id = ?2",
|
|
params![new_path, file_id],
|
|
)?;
|
|
tracing::info!(file_id, new_path, "repaired stale episode_file path");
|
|
}
|
|
for (file_id, episode_id, path) in &to_clear {
|
|
tx.execute("DELETE FROM episode_file WHERE id = ?1", params![file_id])?;
|
|
if let Some(episode_id) = episode_id {
|
|
tx.execute(
|
|
"UPDATE episode SET has_file = 0 WHERE id = ?1",
|
|
params![episode_id],
|
|
)?;
|
|
}
|
|
tracing::info!(
|
|
file_id,
|
|
episode_id,
|
|
path,
|
|
"cleared missing file from library state"
|
|
);
|
|
}
|
|
tx.commit()?;
|
|
|
|
Ok(ReconcileOutcome {
|
|
repaired: to_repair.len(),
|
|
cleared: to_clear.len(),
|
|
aborted: false,
|
|
})
|
|
}
|
|
|
|
/// First file found at any depth under `root` whose file name is exactly
|
|
/// `name` — used to relocate an `episode_file` row whose stored path no
|
|
/// longer exists but whose owning show/movie folder does, e.g. after the
|
|
/// folder itself was renamed. Bounded to one media item's own folder (a
|
|
/// handful of season directories at most), and safe from cross-show
|
|
/// collisions since deterministic filenames are unique within a show.
|
|
fn find_by_basename(root: &Path, name: &std::ffi::OsStr) -> Option<PathBuf> {
|
|
let entries = std::fs::read_dir(root).ok()?;
|
|
for entry in entries.filter_map(|e| e.ok()) {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
if let Some(found) = find_by_basename(&path, name) {
|
|
return Some(found);
|
|
}
|
|
} else if path.file_name() == Some(name) {
|
|
return Some(path);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// First file found at any depth under `root` with the given file stem
|
|
/// (name minus extension) and a recognized video extension — the
|
|
/// transcode-in-place counterpart to `find_by_basename`: a re-encode can
|
|
/// change the container/extension (e.g. Tdarr converting `.mp4` to `.mkv`)
|
|
/// while keeping the stem, which an exact-name match won't see.
|
|
fn find_by_stem(root: &Path, stem: &std::ffi::OsStr) -> Option<PathBuf> {
|
|
let entries = std::fs::read_dir(root).ok()?;
|
|
for entry in entries.filter_map(|e| e.ok()) {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
if let Some(found) = find_by_stem(&path, stem) {
|
|
return Some(found);
|
|
}
|
|
} else {
|
|
let ext_is_video = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.is_some_and(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str()));
|
|
if ext_is_video && path.file_stem() == Some(stem) {
|
|
return Some(path);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Runs ffprobe on `path` and upserts the result into `media_file_probe`
|
|
/// against `episode_file_id` — but only if the file's size or mtime has
|
|
/// actually changed since the last probe, so a routine sweep over a large,
|
|
/// mostly-unchanged library is cheap after the first pass. This is also
|
|
/// what keeps a probe from silently describing stale content after
|
|
/// `reconcile_missing_files` repairs a renamed/transcoded file onto the
|
|
/// same `episode_file.id` — the freshness check catches the change and
|
|
/// forces a re-probe. Returns `true` if a probe actually ran, `false` if
|
|
/// skipped as already up to date. ffprobe itself failing (missing binary,
|
|
/// unreadable/corrupt file) is recorded as `corruption_status =
|
|
/// 'probe_failed'` rather than propagated — probing must never abort a
|
|
/// scan or import.
|
|
pub fn ensure_probed(conn: &Connection, episode_file_id: i64, path: &Path) -> Result<bool> {
|
|
let metadata = std::fs::metadata(path)
|
|
.with_context(|| format!("failed to stat {} for probing", path.display()))?;
|
|
let size = metadata.len() as i64;
|
|
let mtime = metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
|
|
let existing: Option<(i64, i64)> = conn
|
|
.query_row(
|
|
"SELECT probe_size_bytes, probe_mtime FROM media_file_probe WHERE episode_file_id = ?1",
|
|
params![episode_file_id],
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
)
|
|
.optional()?;
|
|
if existing == Some((size, mtime)) {
|
|
return Ok(false);
|
|
}
|
|
|
|
match ffprobe::probe(path) {
|
|
Ok(probe) => upsert_probe(conn, episode_file_id, size, mtime, Some(&probe), "probe_ok")?,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
episode_file_id,
|
|
path = %path.display(),
|
|
error = %e,
|
|
"ffprobe failed; recording as probe_failed and continuing"
|
|
);
|
|
upsert_probe(conn, episode_file_id, size, mtime, None, "probe_failed")?;
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
/// Everything `upsert_probe` needs derived from a `MediaProbe` (or blanked
|
|
/// out entirely when probing itself failed) — a struct rather than the
|
|
/// wall-of-positional-`Option`s this used to be, since that stopped being
|
|
/// readable once the extra-metadata fields were added.
|
|
#[derive(Default)]
|
|
struct ProbeFields {
|
|
duration: Option<f64>,
|
|
container_bitrate: Option<i64>,
|
|
container_format: Option<String>,
|
|
video_codec: Option<String>,
|
|
width: Option<i64>,
|
|
height: Option<i64>,
|
|
video_bitrate: Option<i64>,
|
|
frame_rate: Option<f64>,
|
|
hdr: bool,
|
|
color_transfer: Option<String>,
|
|
audio_codecs: Option<String>,
|
|
audio_langs: Option<String>,
|
|
default_audio_lang: Option<String>,
|
|
default_audio_channels: Option<i64>,
|
|
subtitle_langs: Option<String>,
|
|
raw_json: Option<String>,
|
|
under_quality: bool,
|
|
no_subs: bool,
|
|
no_eng_audio: bool,
|
|
non_eng_default: bool,
|
|
}
|
|
|
|
impl ProbeFields {
|
|
fn from_probe(p: &ffprobe::MediaProbe) -> Self {
|
|
let audio_codecs = p
|
|
.audio
|
|
.iter()
|
|
.filter_map(|a| a.codec.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let audio_langs = p
|
|
.audio
|
|
.iter()
|
|
.filter_map(|a| a.language.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let default_audio = p.audio.iter().find(|a| a.is_default);
|
|
let subtitle_langs = p
|
|
.subtitles
|
|
.iter()
|
|
.filter_map(|s| s.language.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
Self {
|
|
duration: p.duration_secs,
|
|
container_bitrate: p.container_bitrate,
|
|
container_format: p.container_format.clone(),
|
|
video_codec: p.video_codec.clone(),
|
|
width: p.width,
|
|
height: p.height,
|
|
video_bitrate: p.video_bitrate,
|
|
frame_rate: p.frame_rate,
|
|
hdr: p.is_hdr(),
|
|
color_transfer: p.color_transfer.clone(),
|
|
audio_codecs: Some(audio_codecs),
|
|
audio_langs: Some(audio_langs),
|
|
default_audio_lang: default_audio.and_then(|a| a.language.clone()),
|
|
default_audio_channels: default_audio.and_then(|a| a.channels),
|
|
subtitle_langs: Some(subtitle_langs),
|
|
raw_json: Some(p.raw_json.clone()),
|
|
under_quality: p.height.is_some_and(|h| h < UNDER_QUALITY_HEIGHT)
|
|
&& p.width.is_some_and(|w| w < UNDER_QUALITY_WIDTH),
|
|
no_subs: p.subtitles.is_empty(),
|
|
no_eng_audio: !p.has_english_audio(),
|
|
non_eng_default: p.default_audio_is_non_english(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn upsert_probe(
|
|
conn: &Connection,
|
|
episode_file_id: i64,
|
|
size: i64,
|
|
mtime: i64,
|
|
probe: Option<&ffprobe::MediaProbe>,
|
|
corruption_status: &str,
|
|
) -> Result<()> {
|
|
let f = probe.map(ProbeFields::from_probe).unwrap_or_default();
|
|
|
|
conn.execute(
|
|
"INSERT INTO media_file_probe (
|
|
episode_file_id, probed_at, probe_size_bytes, probe_mtime,
|
|
duration_secs, container_bitrate, container_format,
|
|
video_codec, width, height, video_bitrate, frame_rate, hdr, color_transfer,
|
|
audio_codecs, audio_langs, default_audio_lang, default_audio_channels, subtitle_langs,
|
|
raw_ffprobe_json,
|
|
corruption_status, corruption_checked_at,
|
|
flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio
|
|
) VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, NULL, ?21, ?22, ?23, ?24)
|
|
ON CONFLICT(episode_file_id) DO UPDATE SET
|
|
probed_at = excluded.probed_at,
|
|
probe_size_bytes = excluded.probe_size_bytes,
|
|
probe_mtime = excluded.probe_mtime,
|
|
duration_secs = excluded.duration_secs,
|
|
container_bitrate = excluded.container_bitrate,
|
|
container_format = excluded.container_format,
|
|
video_codec = excluded.video_codec,
|
|
width = excluded.width,
|
|
height = excluded.height,
|
|
video_bitrate = excluded.video_bitrate,
|
|
frame_rate = excluded.frame_rate,
|
|
hdr = excluded.hdr,
|
|
color_transfer = excluded.color_transfer,
|
|
audio_codecs = excluded.audio_codecs,
|
|
audio_langs = excluded.audio_langs,
|
|
default_audio_lang = excluded.default_audio_lang,
|
|
default_audio_channels = excluded.default_audio_channels,
|
|
subtitle_langs = excluded.subtitle_langs,
|
|
raw_ffprobe_json = excluded.raw_ffprobe_json,
|
|
corruption_status = excluded.corruption_status,
|
|
corruption_checked_at = NULL,
|
|
flag_under_quality = excluded.flag_under_quality,
|
|
flag_no_subtitles = excluded.flag_no_subtitles,
|
|
flag_no_english_audio = excluded.flag_no_english_audio,
|
|
flag_non_english_default_audio = excluded.flag_non_english_default_audio",
|
|
params![
|
|
episode_file_id,
|
|
size,
|
|
mtime,
|
|
f.duration,
|
|
f.container_bitrate,
|
|
f.container_format,
|
|
f.video_codec,
|
|
f.width,
|
|
f.height,
|
|
f.video_bitrate,
|
|
f.frame_rate,
|
|
f.hdr,
|
|
f.color_transfer,
|
|
f.audio_codecs,
|
|
f.audio_langs,
|
|
f.default_audio_lang,
|
|
f.default_audio_channels,
|
|
f.subtitle_langs,
|
|
f.raw_json,
|
|
corruption_status,
|
|
f.under_quality,
|
|
f.no_subs,
|
|
f.no_eng_audio,
|
|
f.non_eng_default,
|
|
],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Records the outcome of the expensive full-decode corruption check (see
|
|
/// `ffprobe::verify_decodable`) against an already-probed row. Separate
|
|
/// from `upsert_probe` since this is only ever called by the opt-in
|
|
/// `verify-library` pass, never by a routine probe.
|
|
pub fn record_decode_check(
|
|
conn: &Connection,
|
|
episode_file_id: i64,
|
|
result: &ffprobe::DecodeCheck,
|
|
) -> Result<()> {
|
|
let status = match result {
|
|
ffprobe::DecodeCheck::Ok => "decode_ok",
|
|
ffprobe::DecodeCheck::Corrupt(_) => "decode_failed",
|
|
};
|
|
conn.execute(
|
|
"UPDATE media_file_probe SET corruption_status = ?1, corruption_checked_at = datetime('now')
|
|
WHERE episode_file_id = ?2",
|
|
params![status, episode_file_id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// True when a just-probed file has a problem worth alerting on
|
|
/// immediately (used for the post-import ground-truth quality check) —
|
|
/// deliberately narrower than "any flag is set": `flag_no_subtitles` and
|
|
/// `flag_non_english_default_audio` are common and already handled
|
|
/// elsewhere (the latter is fixed automatically by the remux-at-import step
|
|
/// right before this ever runs), so surfacing them here on every single
|
|
/// import would just be noise. Under-quality and probe failure are the two
|
|
/// signals that mean "this import silently isn't what it claimed to be."
|
|
fn probe_indicates_import_problem(conn: &Connection, episode_file_id: i64) -> Result<bool> {
|
|
conn.query_row(
|
|
"SELECT flag_under_quality OR corruption_status = 'probe_failed'
|
|
FROM media_file_probe WHERE episode_file_id = ?1",
|
|
params![episode_file_id],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map(|r| r.unwrap_or(false))
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct ProbeSweepReport {
|
|
pub probed: usize,
|
|
pub skipped_up_to_date: usize,
|
|
pub failed: usize,
|
|
}
|
|
|
|
/// Incremental sweep over every tracked file: probes anything not probed
|
|
/// yet, or whose content has drifted (size/mtime) since it last was — the
|
|
/// backlog-and-drift half of keeping `media_file_probe` current, alongside
|
|
/// the inline probing done right after a file is linked or imported. Files
|
|
/// missing from disk are left alone (that's `reconcile_missing_files`'s
|
|
/// job, not this one's) rather than double-handled here.
|
|
/// Caps how many files actually get *probed* (not just checked-and-skipped)
|
|
/// in one `probe_library` call. Skipping this check runs an unbounded first
|
|
/// sweep against a large existing library in a single pass — each probe
|
|
/// spawns a real `ffprobe` subprocess, and the whole call holds the shared
|
|
/// `Connection` mutex the entire time (the same mutex `main.rs`'s grab/
|
|
/// import/search tickers need), so an unbounded sweep would stall those
|
|
/// cycles for however long a full library backlog takes. Capping it instead
|
|
/// spreads a large backlog across successive hourly ticks — see
|
|
/// `main.rs`'s reconcile ticker, which this shares a tick with.
|
|
const PROBE_SWEEP_BATCH_LIMIT: usize = 200;
|
|
|
|
pub fn probe_library(conn: &Connection) -> Result<ProbeSweepReport> {
|
|
let mut stmt = conn.prepare("SELECT id, path FROM episode_file")?;
|
|
let rows: Vec<(i64, String)> = stmt
|
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
|
.collect::<rusqlite::Result<_>>()?;
|
|
|
|
let mut report = ProbeSweepReport::default();
|
|
for (episode_file_id, path) in rows {
|
|
if report.probed >= PROBE_SWEEP_BATCH_LIMIT {
|
|
break;
|
|
}
|
|
let p = Path::new(&path);
|
|
if !p.exists() {
|
|
continue;
|
|
}
|
|
match ensure_probed(conn, episode_file_id, p) {
|
|
Ok(true) => report.probed += 1,
|
|
Ok(false) => report.skipped_up_to_date += 1,
|
|
Err(e) => {
|
|
tracing::warn!(episode_file_id, path, error = %e, "probe sweep: failed to probe file");
|
|
report.failed += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct VerifyLibraryReport {
|
|
pub verified_ok: usize,
|
|
pub corrupt: usize,
|
|
pub errors: usize,
|
|
}
|
|
|
|
/// Runs the expensive full-decode corruption check
|
|
/// (`ffprobe::verify_decodable`) against every file whose cheap header
|
|
/// probe succeeded but hasn't yet been decode-verified
|
|
/// (`corruption_status = 'probe_ok'`) — confirming a file actually decodes
|
|
/// end-to-end, not just that its container header parses. `probe_failed`
|
|
/// files are skipped: ffprobe already couldn't parse them, so a decode
|
|
/// attempt would only confirm the same failure at much higher cost.
|
|
/// Deliberately never run by any ticker (this can take minutes per file —
|
|
/// see `ffprobe::verify_decodable`'s doc comment) — only ever runs when
|
|
/// explicitly invoked via `breadarrd verify-library`.
|
|
pub fn verify_library(conn: &Connection) -> Result<VerifyLibraryReport> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT ef.id, ef.path FROM episode_file ef
|
|
JOIN media_file_probe p ON p.episode_file_id = ef.id
|
|
WHERE p.corruption_status = 'probe_ok'",
|
|
)?;
|
|
let rows: Vec<(i64, String)> = stmt
|
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
|
.collect::<rusqlite::Result<_>>()?;
|
|
|
|
let mut report = VerifyLibraryReport::default();
|
|
for (episode_file_id, path) in rows {
|
|
let source_path = Path::new(&path);
|
|
if !source_path.exists() {
|
|
continue; // reconcile's job, not ours
|
|
}
|
|
match ffprobe::verify_decodable(source_path) {
|
|
Ok(result) => {
|
|
record_decode_check(conn, episode_file_id, &result)?;
|
|
match result {
|
|
ffprobe::DecodeCheck::Ok => {
|
|
report.verified_ok += 1;
|
|
tracing::info!(episode_file_id, path, "verify library: decodes cleanly");
|
|
}
|
|
ffprobe::DecodeCheck::Corrupt(detail) => {
|
|
report.corrupt += 1;
|
|
tracing::warn!(
|
|
episode_file_id,
|
|
path,
|
|
detail,
|
|
"verify library: decode failed, file looks corrupt"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(episode_file_id, path, error = %e, "verify library: failed to run decode check");
|
|
report.errors += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct RemuxBacklogReport {
|
|
pub remuxed: usize,
|
|
pub skipped_not_mkv: usize,
|
|
pub skipped_no_english_track: usize,
|
|
pub errors: usize,
|
|
}
|
|
|
|
/// Applies the same "promote the English audio track to default" fix
|
|
/// (`mkv::remux_english_default`) that already runs automatically right
|
|
/// after a fresh download, but against files *already* sitting in the
|
|
/// library — the backlog that existed before this feature shipped, or any
|
|
/// file `media_file_probe` has flagged since. Deliberately not wired to any
|
|
/// automatic ticker (unlike `probe_library`, which is read-mostly): this
|
|
/// rewrites real files in the library, so it only ever runs when explicitly
|
|
/// invoked (see `breadarrd remux-backlog`).
|
|
///
|
|
/// Only `.mkv` files are eligible — `mkv::remux_english_default` retags
|
|
/// tracks via `mkvmerge`, the same constraint the at-import-time fix
|
|
/// already has.
|
|
pub fn remux_backlog(conn: &Connection) -> Result<RemuxBacklogReport> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT ef.id, ef.path FROM episode_file ef
|
|
JOIN media_file_probe p ON p.episode_file_id = ef.id
|
|
WHERE p.flag_non_english_default_audio = 1",
|
|
)?;
|
|
let rows: Vec<(i64, String)> = stmt
|
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
|
.collect::<rusqlite::Result<_>>()?;
|
|
|
|
let mut report = RemuxBacklogReport::default();
|
|
for (episode_file_id, path) in rows {
|
|
let source_path = Path::new(&path);
|
|
if !source_path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.is_some_and(|e| e.eq_ignore_ascii_case("mkv"))
|
|
{
|
|
report.skipped_not_mkv += 1;
|
|
continue;
|
|
}
|
|
if !source_path.exists() {
|
|
continue; // reconcile's job, not ours
|
|
}
|
|
|
|
match remux_one_backlog_file(conn, episode_file_id, source_path) {
|
|
Ok(true) => report.remuxed += 1,
|
|
Ok(false) => report.skipped_no_english_track += 1,
|
|
Err(e) => {
|
|
tracing::warn!(episode_file_id, path, error = %e, "remux backlog: failed to remux file");
|
|
report.errors += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
/// Returns `true` if a remux actually happened, `false` if there was no
|
|
/// English track to promote (nothing to do — not an error).
|
|
fn remux_one_backlog_file(conn: &Connection, episode_file_id: i64, path: &Path) -> Result<bool> {
|
|
let tracks = mkv::inspect_audio_tracks(path)?;
|
|
if !mkv::has_english_track(&tracks) {
|
|
return Ok(false);
|
|
}
|
|
|
|
let tmp = path.with_extension("fixed.mkv");
|
|
mkv::remux_english_default(path, &tmp, &tracks)?;
|
|
|
|
// Same atomic-swap shape as `copy_via_temp_file`: rename the freshly
|
|
// remuxed output over the original on the same filesystem, so a crash
|
|
// mid-swap can never leave a half-written file at the real path.
|
|
std::fs::rename(&tmp, path)?;
|
|
|
|
let size_bytes = std::fs::metadata(path)?.len();
|
|
conn.execute(
|
|
"UPDATE episode_file SET size_bytes = ?1 WHERE id = ?2",
|
|
params![size_bytes, episode_file_id],
|
|
)?;
|
|
// Force a re-probe even though size may coincidentally match — a
|
|
// flag-only remux barely changes file size, so the freshness check
|
|
// could otherwise skip it. Clearing the stored probe row is simpler
|
|
// than adding a "force" parameter to `ensure_probed` for this one caller.
|
|
conn.execute(
|
|
"DELETE FROM media_file_probe WHERE episode_file_id = ?1",
|
|
params![episode_file_id],
|
|
)?;
|
|
ensure_probed(conn, episode_file_id, path)?;
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
/// Translates a path qBittorrent reports via its API into one breadarr can
|
|
/// actually open — needed when qBittorrent runs in a container (its own
|
|
/// downloads mounted at some internal prefix like "/downloads") while
|
|
/// breadarr runs natively on the same host. A no-op when either prefix is
|
|
/// empty (qBittorrent's reported path is used as-is).
|
|
fn remap_path(reported: &str, container_prefix: &str, host_prefix: &str) -> PathBuf {
|
|
if container_prefix.is_empty() || host_prefix.is_empty() {
|
|
return PathBuf::from(reported);
|
|
}
|
|
match reported.strip_prefix(container_prefix) {
|
|
Some(rest) => PathBuf::from(format!("{host_prefix}{rest}")),
|
|
None => PathBuf::from(reported),
|
|
}
|
|
}
|
|
|
|
/// Marker directory name for the seeding-preserving staging area — checked
|
|
/// as a plain substring of a torrent's reported `content_path` to tell
|
|
/// whether it's already been relocated there in a previous cycle.
|
|
const STAGING_DIR_NAME: &str = ".breadarr-staging";
|
|
|
|
/// How many times to poll qBittorrent for a `setLocation` move to finish
|
|
/// before giving up for this cycle (it'll simply be retried next cycle —
|
|
/// see `relocate_completed_to_staging`).
|
|
const RELOCATE_POLL_ATTEMPTS: u32 = 5;
|
|
const RELOCATE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
|
|
|
|
/// Finds the nearest ancestor of `path` that actually exists — `path`
|
|
/// itself (e.g. a show's season folder) may not have been created yet.
|
|
fn nearest_existing_ancestor(path: &Path) -> Result<PathBuf> {
|
|
let mut current = path;
|
|
loop {
|
|
if current.exists() {
|
|
return Ok(current.to_path_buf());
|
|
}
|
|
current = current
|
|
.parent()
|
|
.with_context(|| format!("no existing ancestor found for {}", path.display()))?;
|
|
}
|
|
}
|
|
|
|
/// Finds the filesystem mount-point directory containing `path`, by
|
|
/// walking up parents until the device id changes. Used to place the
|
|
/// seeding-staging directory on the same physical filesystem as the final
|
|
/// destination — the whole point of staging is that the later hardlink-out
|
|
/// in `import_one` is guaranteed to succeed as a true hardlink rather than
|
|
/// silently falling back to a full copy, and a hardlink can never cross a
|
|
/// filesystem boundary.
|
|
fn find_mount_root(path: &Path) -> Result<PathBuf> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
let start = nearest_existing_ancestor(path)?;
|
|
let dev = std::fs::metadata(&start)?.dev();
|
|
let mut current = start;
|
|
loop {
|
|
let Some(parent) = current.parent() else {
|
|
return Ok(current);
|
|
};
|
|
let Ok(parent_meta) = std::fs::metadata(parent) else {
|
|
return Ok(current);
|
|
};
|
|
if parent_meta.dev() != dev {
|
|
return Ok(current);
|
|
}
|
|
current = parent.to_path_buf();
|
|
}
|
|
}
|
|
|
|
/// The staging directory a given torrent's data should be relocated to —
|
|
/// on the same filesystem as `dest_dir`, named by torrent hash so multiple
|
|
/// torrents' leftover extras (samples/nfo/screenshots) never collide.
|
|
fn staging_dir_for(dest_dir: &Path, torrent_hash: &str) -> Result<PathBuf> {
|
|
let mount_root = find_mount_root(dest_dir)?;
|
|
Ok(mount_root.join(STAGING_DIR_NAME).join(torrent_hash))
|
|
}
|
|
|
|
/// For every pending grab whose torrent has finished downloading but whose
|
|
/// data isn't already staged, relocates it via qBittorrent's own
|
|
/// `setLocation` to a directory on the same filesystem as its eventual
|
|
/// destination, then waits (briefly, bounded) for the move to actually
|
|
/// finish — `setLocation` returns before the physical move completes.
|
|
///
|
|
/// Best-effort and self-healing by design: a grab that isn't staged yet
|
|
/// this cycle is simply retried on the next one (nothing here ever fails
|
|
/// the whole import cycle), so a slow move or a transient qBit API error
|
|
/// never blocks or loses anything — it just costs one extra cycle.
|
|
///
|
|
/// Returns whether anything was actually relocated, so the caller knows
|
|
/// whether it's worth re-fetching the torrent list before importing (a
|
|
/// relocated torrent's `content_path` only reflects its new home after a
|
|
/// fresh `list_torrents` call).
|
|
async fn relocate_completed_to_staging(
|
|
qbit: &QbitClient,
|
|
pending: &[PendingGrab],
|
|
torrents: &[crate::qbit::TorrentInfo],
|
|
category: &str,
|
|
) -> bool {
|
|
let mut relocated_any = false;
|
|
for grab in pending {
|
|
let Some(torrent) = torrents.iter().find(|t| t.hash == grab.torrent_hash()) else {
|
|
continue;
|
|
};
|
|
if torrent.progress < 1.0 {
|
|
continue;
|
|
}
|
|
if torrent.content_path.contains(STAGING_DIR_NAME) {
|
|
continue; // already staged in a previous cycle
|
|
}
|
|
|
|
let staging = match staging_dir_for(Path::new(grab.root_folder()), grab.torrent_hash()) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %e,
|
|
hash = grab.torrent_hash(),
|
|
"could not determine a staging directory this cycle"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if let Err(e) = qbit
|
|
.set_location(grab.torrent_hash(), &staging.to_string_lossy())
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
error = %e,
|
|
hash = grab.torrent_hash(),
|
|
"qbit relocate-to-staging failed, will retry next cycle"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if wait_for_relocation(qbit, grab.torrent_hash(), category, &staging).await {
|
|
relocated_any = true;
|
|
} else {
|
|
tracing::warn!(
|
|
hash = grab.torrent_hash(),
|
|
"qbit relocate-to-staging didn't finish in time, will retry next cycle"
|
|
);
|
|
}
|
|
}
|
|
relocated_any
|
|
}
|
|
|
|
/// Waits for qBittorrent to report the torrent's `content_path` as staged,
|
|
/// then verifies the reported path actually landed where expected —
|
|
/// qBittorrent (when it's running in its own Docker container) reports *its
|
|
/// own* filesystem view, and `staging_dir_for`/`set_location` currently
|
|
/// rely on every library mount being set up as an identity mount (container
|
|
/// path == host path) for that reported path to be directly meaningful
|
|
/// from the host's side. That's a deployment convention, not something the
|
|
/// code enforces, so it can be wrong for a given install's mount layout. If
|
|
/// it is, treating the reported path as trustworthy without checking could
|
|
/// let `import_one`'s later hardlink-out silently fall back to a full
|
|
/// cross-device copy (or fail outright) instead of the guaranteed-cheap
|
|
/// hardlink staging exists to provide — so this confirms the reported path
|
|
/// resolves, from the host, to the *same physical filesystem* as
|
|
/// `expected_staging` before trusting it.
|
|
async fn wait_for_relocation(
|
|
qbit: &QbitClient,
|
|
hash: &str,
|
|
category: &str,
|
|
expected_staging: &Path,
|
|
) -> bool {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
for _ in 0..RELOCATE_POLL_ATTEMPTS {
|
|
tokio::time::sleep(RELOCATE_POLL_INTERVAL).await;
|
|
let Ok(torrents) = qbit.list_torrents(Some(category)).await else {
|
|
continue;
|
|
};
|
|
let Some(t) = torrents.iter().find(|t| t.hash == hash) else {
|
|
continue;
|
|
};
|
|
if !t.content_path.contains(STAGING_DIR_NAME) {
|
|
continue;
|
|
}
|
|
let reported = Path::new(&t.content_path);
|
|
match (
|
|
std::fs::metadata(reported),
|
|
std::fs::metadata(expected_staging),
|
|
) {
|
|
(Ok(reported_meta), Ok(expected_meta))
|
|
if reported_meta.dev() == expected_meta.dev() =>
|
|
{
|
|
return true;
|
|
}
|
|
_ => {
|
|
tracing::error!(
|
|
hash,
|
|
reported = %t.content_path,
|
|
expected = %expected_staging.display(),
|
|
"qbit reports the torrent as staged, but its content_path isn't visible \
|
|
on the same host filesystem as expected — the container's mount for this \
|
|
library path may not be an identity mount; refusing to trust this relocation"
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
pub async fn run_import_cycle(
|
|
conn: &Connection,
|
|
qbit: &QbitClient,
|
|
jellyfin: Option<&JellyfinClient>,
|
|
category: &str,
|
|
container_downloads_path: &str,
|
|
host_downloads_path: &str,
|
|
) -> Result<ImportStats> {
|
|
let pending = fetch_pending_grabs(conn)?;
|
|
if pending.is_empty() {
|
|
return Ok(ImportStats::default());
|
|
}
|
|
|
|
let torrents = qbit.list_torrents(Some(category)).await?;
|
|
|
|
// Relocate completed-but-not-yet-staged torrents onto the same
|
|
// filesystem as their destination *before* importing, so the
|
|
// hardlink-out below (`link_or_copy_file`, unchanged) is a true
|
|
// hardlink instead of a full cross-drive copy — qBittorrent keeps
|
|
// seeding indefinitely from the staged location afterward, with no
|
|
// permanent second copy of the data anywhere.
|
|
let relocated_any = relocate_completed_to_staging(qbit, &pending, &torrents, category).await;
|
|
let torrents = if relocated_any {
|
|
qbit.list_torrents(Some(category)).await?
|
|
} else {
|
|
torrents
|
|
};
|
|
|
|
let stats = process_pending_grabs(
|
|
conn,
|
|
&pending,
|
|
&torrents,
|
|
container_downloads_path,
|
|
host_downloads_path,
|
|
)?;
|
|
|
|
if stats.imported > 0 {
|
|
if let Some(jellyfin) = jellyfin {
|
|
jellyfin.refresh_library().await?;
|
|
}
|
|
}
|
|
|
|
Ok(stats)
|
|
}
|
|
|
|
/// The actual hash-matching, progress-gating, stall/missing-detection, and
|
|
/// import decision logic — split out from `run_import_cycle` so it's
|
|
/// testable against a synthetic `&[TorrentInfo]` instead of requiring a
|
|
/// real qBittorrent server. `run_import_cycle`'s only other job is the one
|
|
/// network call (`list_torrents`) and the post-import Jellyfin refresh,
|
|
/// neither of which this function touches. This is exactly the kind of
|
|
/// unmockable I/O seam that let the `MagnetRejected` silent-failure bug
|
|
/// hide for as long as it did.
|
|
fn process_pending_grabs(
|
|
conn: &Connection,
|
|
pending: &[PendingGrab],
|
|
torrents: &[crate::qbit::TorrentInfo],
|
|
container_downloads_path: &str,
|
|
host_downloads_path: &str,
|
|
) -> Result<ImportStats> {
|
|
let mut stats = ImportStats::default();
|
|
|
|
for grab in pending {
|
|
let Some(torrent) = torrents.iter().find(|t| t.hash == grab.torrent_hash()) else {
|
|
if grab_missing_past_grace(conn, grab.release_id())? {
|
|
fail_grab(conn, grab, "torrent hash absent from qBittorrent")?;
|
|
stats.failed += 1;
|
|
}
|
|
continue;
|
|
};
|
|
if torrent.progress < 1.0 {
|
|
stats.skipped_incomplete += 1;
|
|
update_grab_progress(conn, grab.release_id(), torrent.progress)?;
|
|
if grab_is_stalled(conn, grab.release_id())? {
|
|
fail_grab(
|
|
conn,
|
|
grab,
|
|
"no progress for longer than the stall threshold",
|
|
)?;
|
|
stats.failed += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if torrent.state == "moving" {
|
|
// qBittorrent's own `setLocation` relocation (or a manual move)
|
|
// is still physically in flight — `content_path` may already
|
|
// point at the new location while the actual bytes are still
|
|
// being copied there. Importing now risks hardlinking/copying
|
|
// a partially-moved (truncated) file into the library and
|
|
// marking it complete. Simply wait; this is checked every
|
|
// cycle, so it proceeds as soon as the move finishes.
|
|
stats.skipped_incomplete += 1;
|
|
continue;
|
|
}
|
|
|
|
let content_path = remap_path(
|
|
&torrent.content_path,
|
|
container_downloads_path,
|
|
host_downloads_path,
|
|
);
|
|
|
|
if let PendingGrab::SeasonPack {
|
|
release_id,
|
|
media_item_id,
|
|
series_title,
|
|
season_number,
|
|
root_folder,
|
|
..
|
|
} = grab
|
|
{
|
|
match import_season_pack(
|
|
conn,
|
|
*release_id,
|
|
*media_item_id,
|
|
series_title,
|
|
*season_number,
|
|
root_folder,
|
|
&content_path,
|
|
) {
|
|
Ok(outcome) => {
|
|
stats.imported += outcome.episodes_imported;
|
|
stats.quality_flagged += outcome.quality_flagged;
|
|
}
|
|
Err(e) => {
|
|
let error_count = record_import_error(conn, *release_id)?;
|
|
if error_count >= MAX_IMPORT_ERRORS {
|
|
fail_grab(
|
|
conn,
|
|
grab,
|
|
&format!("season pack import failed {error_count} times in a row: {e}"),
|
|
)?;
|
|
stats.failed += 1;
|
|
} else {
|
|
tracing::warn!(
|
|
error = %e,
|
|
release_id = *release_id,
|
|
error_count,
|
|
"season pack import failed"
|
|
);
|
|
stats.errors += 1;
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
match import_one(conn, grab, &content_path) {
|
|
Ok(ImportOutcome::Imported {
|
|
remuxed,
|
|
quality_flagged,
|
|
}) => {
|
|
stats.imported += 1;
|
|
if remuxed {
|
|
stats.remuxed += 1;
|
|
}
|
|
if quality_flagged {
|
|
stats.quality_flagged += 1;
|
|
}
|
|
}
|
|
Ok(ImportOutcome::SkippedAlreadyHaveBetter) => {}
|
|
Err(e) => {
|
|
let error_count = record_import_error(conn, grab.release_id())?;
|
|
if error_count >= MAX_IMPORT_ERRORS {
|
|
fail_grab(
|
|
conn,
|
|
grab,
|
|
&format!("import failed {error_count} times in a row: {e}"),
|
|
)?;
|
|
stats.failed += 1;
|
|
} else {
|
|
tracing::warn!(
|
|
error = %e,
|
|
release_id = grab.release_id(),
|
|
error_count,
|
|
"import failed"
|
|
);
|
|
stats.errors += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(stats)
|
|
}
|
|
|
|
/// What actually happened when `import_one` was asked to import a
|
|
/// completed torrent — distinct from an error, `SkippedAlreadyHaveBetter`
|
|
/// is a deliberate no-op (see `import_one`'s dest-collision check), not a
|
|
/// failure, so callers shouldn't count it toward `ImportStats::imported`.
|
|
enum ImportOutcome {
|
|
Imported {
|
|
remuxed: bool,
|
|
quality_flagged: bool,
|
|
},
|
|
SkippedAlreadyHaveBetter,
|
|
}
|
|
|
|
fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Result<ImportOutcome> {
|
|
let source_path = locate_video_file(content_path)?;
|
|
let ext = source_path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("mkv")
|
|
.to_string();
|
|
|
|
let mut remuxed = false;
|
|
let mut working_path = source_path.clone();
|
|
|
|
if ext.eq_ignore_ascii_case("mkv") {
|
|
let tracks = mkv::inspect_audio_tracks(&source_path)?;
|
|
let needs_fix =
|
|
!mkv::default_track_is_english_or_unset(&tracks) && mkv::has_english_track(&tracks);
|
|
if needs_fix {
|
|
let tmp = source_path.with_extension("fixed.mkv");
|
|
mkv::remux_english_default(&source_path, &tmp, &tracks)?;
|
|
// `source_path` is qBittorrent's actual seeding payload for
|
|
// this torrent — deleting it here, before the free-space check
|
|
// and the import below have even run, defeats the whole
|
|
// staging design (whose point is to keep seeding intact) and
|
|
// risks real data loss if either subsequent step fails: the
|
|
// original would already be gone, `working_path` might not
|
|
// have made it into the library either, and qBittorrent can't
|
|
// necessarily re-fetch a dead swarm. `link_or_copy_file`
|
|
// already leaves its source untouched for exactly this reason
|
|
// in the non-remux path (see its own doc comment) — the remux
|
|
// scratch output below gets the same treatment: only removed
|
|
// once it's been successfully imported.
|
|
working_path = tmp;
|
|
remuxed = true;
|
|
}
|
|
// Anime-JP-fallback case (no English track at all, allowed through
|
|
// by the gate for anime) has nothing to fix — falls through to a
|
|
// clean import below, same as an already-English-default file.
|
|
}
|
|
|
|
let (dest_dir, filename, episode_id) = match grab {
|
|
PendingGrab::Episode {
|
|
series_title,
|
|
season_number,
|
|
episode_number,
|
|
episode_title,
|
|
root_folder,
|
|
episode_id,
|
|
..
|
|
} => {
|
|
let filename = deterministic_filename(
|
|
series_title,
|
|
*season_number,
|
|
*episode_number,
|
|
episode_title.as_deref(),
|
|
&ext,
|
|
);
|
|
let dest_dir = season_dir(root_folder, *season_number);
|
|
(dest_dir, filename, Some(*episode_id))
|
|
}
|
|
PendingGrab::Movie {
|
|
title,
|
|
year,
|
|
root_folder,
|
|
..
|
|
} => {
|
|
let filename = deterministic_movie_filename(title, *year, &ext);
|
|
(PathBuf::from(root_folder), filename, None)
|
|
}
|
|
// `process_pending_grabs` dispatches `SeasonPack` to
|
|
// `import_season_pack` and never reaches this function with one.
|
|
PendingGrab::SeasonPack { .. } => unreachable!(
|
|
"SeasonPack grabs are handled by import_season_pack before import_one is ever called"
|
|
),
|
|
};
|
|
|
|
std::fs::create_dir_all(&dest_dir)?;
|
|
let dest = dest_dir.join(&filename);
|
|
|
|
// Set below (to the renamed-aside stale file's path) only when this
|
|
// import is an upgrade over an existing, worse-scoring file — see the
|
|
// `dest.exists()` branch. Used to restore the original on any failure
|
|
// between here and the replacement being confirmed on disk, and to
|
|
// gate the deferred cleanup (old row + old file) once it succeeds.
|
|
let mut old_sibling: Option<PathBuf> = None;
|
|
|
|
// The deterministic filename means a second release for the same
|
|
// episode/movie collides on this exact path. `link_or_copy_file`'s copy
|
|
// fallback renames into place, which *silently overwrites* an existing
|
|
// file with no comparison at all — a lower-scored duplicate arriving
|
|
// second (e.g. a 480p release importing after an already-imported
|
|
// 1080p one, entirely possible before resolution was scored, and still
|
|
// possible from a race between two grabs of the same episode) would
|
|
// quietly replace the better file already in the library. Checked here
|
|
// rather than left to the filesystem to decide by import order.
|
|
if dest.exists() {
|
|
let current_score: f32 = conn
|
|
.query_row(
|
|
"SELECT score FROM release WHERE id = ?1",
|
|
params![grab.release_id()],
|
|
|row| row.get::<_, Option<f32>>(0),
|
|
)?
|
|
.unwrap_or(0.0);
|
|
let existing_best: Option<f32> = conn.query_row(
|
|
"SELECT MAX(score) FROM release WHERE status = 'imported' AND id != ?1
|
|
AND ((episode_id = ?2 AND ?2 IS NOT NULL) OR (media_item_id = ?3 AND ?2 IS NULL))",
|
|
params![grab.release_id(), episode_id, grab.media_item_id()],
|
|
|row| row.get(0),
|
|
)?;
|
|
if existing_best.is_some_and(|best| best >= current_score) {
|
|
conn.execute(
|
|
"UPDATE release SET status = 'upgraded' WHERE id = ?1",
|
|
params![grab.release_id()],
|
|
)?;
|
|
crate::db::record_event(
|
|
conn,
|
|
grab.media_item_id(),
|
|
episode_id,
|
|
// event_history.event_type's CHECK constraint doesn't have
|
|
// an "upgraded" value (unlike release.status, which does) —
|
|
// reusing "failed" here rather than a schema migration for
|
|
// one more enum value; the detail text carries the real
|
|
// reason.
|
|
"failed",
|
|
&format!(
|
|
"skipped import: already have a file at {} scoring {:.1} or better (this release scored {current_score:.1})",
|
|
dest.display(),
|
|
existing_best.unwrap_or(0.0)
|
|
),
|
|
)?;
|
|
if remuxed {
|
|
std::fs::remove_file(&working_path).ok();
|
|
}
|
|
return Ok(ImportOutcome::SkippedAlreadyHaveBetter);
|
|
}
|
|
// The new file scores strictly better than what's currently there.
|
|
// Move the stale file sideways to a `.old` sibling — rather than
|
|
// deleting it and its tracking row outright — so the replacement is
|
|
// placed and confirmed *before* the original is actually given up.
|
|
// A `rename` (not a delete) still frees up `dest` for the primary
|
|
// hardlink path (`std::fs::hard_link` fails outright if the
|
|
// destination already exists), so an upgrade is still a cheap
|
|
// hardlink swap in the common case; it just also means that if the
|
|
// free-space check or `link_or_copy_file` below fails (I/O error,
|
|
// dest dir vanished, disk full), the original file gets moved back
|
|
// into place instead of being gone for good. The DB row is left
|
|
// alone until the replacement is confirmed on disk, for the same
|
|
// reason.
|
|
old_sibling = Some(PathBuf::from(format!("{}.old", dest.display())));
|
|
std::fs::rename(&dest, old_sibling.as_ref().unwrap())?;
|
|
}
|
|
|
|
let needed_bytes = std::fs::metadata(&working_path)?.len();
|
|
if insufficient_space(&dest_dir, needed_bytes)? {
|
|
if let Some(old_sibling) = &old_sibling {
|
|
std::fs::rename(old_sibling, &dest).ok();
|
|
}
|
|
anyhow::bail!(
|
|
"not enough free space at {} for {needed_bytes} bytes (source: {})",
|
|
dest_dir.display(),
|
|
working_path.display()
|
|
);
|
|
}
|
|
|
|
if let Err(err) = link_or_copy_file(&working_path, &dest) {
|
|
// Restore the original file rather than leaving the user with
|
|
// neither the old file nor the new one — this is the exact failure
|
|
// mode a `DELETE`-then-place ordering used to leave unrecoverable.
|
|
if let Some(old_sibling) = &old_sibling {
|
|
std::fs::rename(old_sibling, &dest).ok();
|
|
}
|
|
return Err(err);
|
|
}
|
|
if remuxed {
|
|
// `working_path` here is the scratch remux output, not qBittorrent's
|
|
// original content file (which was already removed above, in the
|
|
// remux branch, to make way for it) — nothing else reads it, so
|
|
// unlike the plain-import case there's no seeding reason to keep it.
|
|
std::fs::remove_file(&working_path).ok();
|
|
}
|
|
|
|
// The replacement is confirmed in place on disk — only now is it safe
|
|
// to drop the old tracking row and the renamed-aside original.
|
|
if let Some(old_sibling) = old_sibling {
|
|
conn.execute(
|
|
"DELETE FROM episode_file WHERE path = ?1",
|
|
params![dest.to_string_lossy()],
|
|
)?;
|
|
std::fs::remove_file(&old_sibling).ok();
|
|
}
|
|
|
|
let size_bytes = std::fs::metadata(&dest)?.len();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, ?4, 'none')",
|
|
params![episode_id, grab.media_item_id(), dest.to_string_lossy(), size_bytes],
|
|
)?;
|
|
let episode_file_id = conn.last_insert_rowid();
|
|
if let Some(episode_id) = episode_id {
|
|
conn.execute(
|
|
"UPDATE episode SET has_file = 1 WHERE id = ?1",
|
|
params![episode_id],
|
|
)?;
|
|
}
|
|
conn.execute(
|
|
"UPDATE release SET status = 'imported' WHERE id = ?1",
|
|
params![grab.release_id()],
|
|
)?;
|
|
crate::db::record_event(
|
|
conn,
|
|
grab.media_item_id(),
|
|
episode_id,
|
|
"imported",
|
|
&format!("path={} remuxed={remuxed}", dest.display()),
|
|
)?;
|
|
|
|
// Ground-truth check on the file that actually landed, not the
|
|
// release's claimed title text — this is what catches e.g. an
|
|
// untagged-resolution release that turns out to really be SD. Probing
|
|
// failure itself must never fail an otherwise-successful import.
|
|
let quality_flagged = match ensure_probed(conn, episode_file_id, &dest) {
|
|
Ok(_) => probe_indicates_import_problem(conn, episode_file_id)?,
|
|
Err(e) => {
|
|
tracing::warn!(episode_file_id, error = %e, "post-import probing failed");
|
|
false
|
|
}
|
|
};
|
|
if quality_flagged {
|
|
crate::db::record_event(
|
|
conn,
|
|
grab.media_item_id(),
|
|
episode_id,
|
|
// event_history's event_type CHECK has no dedicated value for
|
|
// this — reusing "failed" per the same established workaround
|
|
// used elsewhere in this file (see the dest-collision skip case
|
|
// above); the detail text carries the real reason.
|
|
"failed",
|
|
&format!(
|
|
"quality concern flagged after import: path={}",
|
|
dest.display()
|
|
),
|
|
)?;
|
|
}
|
|
|
|
Ok(ImportOutcome::Imported {
|
|
remuxed,
|
|
quality_flagged,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct SeasonPackImportOutcome {
|
|
episodes_imported: usize,
|
|
episodes_already_had_better: usize,
|
|
episodes_unmatched: usize,
|
|
quality_flagged: usize,
|
|
}
|
|
|
|
/// Imports a season-pack/batch torrent: unlike `import_one` (which resolves
|
|
/// to exactly one destination file), a pack's `content_path` is typically a
|
|
/// directory holding one file per episode. Each file is matched to its own
|
|
/// episode independently by re-parsing *its own filename* — batch releases
|
|
/// almost always name each inner file with its own SxxExx marker even
|
|
/// though the outer torrent name doesn't (that's precisely what made it
|
|
/// unparseable as a single episode in the first place, see
|
|
/// `looks_like_season_pack`). A file that can't be matched to a tracked
|
|
/// episode is skipped, not fatal to the rest of the pack.
|
|
///
|
|
/// Deliberately does not run the mkv English-audio-default remux fix
|
|
/// `import_one` applies inline — doing that per-file here would roughly
|
|
/// double a season pack's import time. Any file that needs it is still
|
|
/// reachable afterward via `remux_backlog`, which sweeps the whole library
|
|
/// including season-pack imports.
|
|
fn import_season_pack(
|
|
conn: &Connection,
|
|
release_id: i64,
|
|
media_item_id: i64,
|
|
series_title: &str,
|
|
season_number: u32,
|
|
root_folder: &str,
|
|
content_path: &Path,
|
|
) -> Result<SeasonPackImportOutcome> {
|
|
let release_score: f32 = conn
|
|
.query_row(
|
|
"SELECT score FROM release WHERE id = ?1",
|
|
params![release_id],
|
|
|row| row.get::<_, Option<f32>>(0),
|
|
)?
|
|
.unwrap_or(0.0);
|
|
|
|
let candidates = if content_path.is_file() {
|
|
vec![content_path.to_path_buf()]
|
|
} else {
|
|
walk_files(content_path)?
|
|
};
|
|
let video_files: Vec<PathBuf> = candidates
|
|
.into_iter()
|
|
.filter(|p| {
|
|
p.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str()))
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
if video_files.is_empty() {
|
|
anyhow::bail!("no video files found under {}", content_path.display());
|
|
}
|
|
|
|
std::fs::create_dir_all(root_folder)?;
|
|
|
|
let mut outcome = SeasonPackImportOutcome::default();
|
|
for source_path in &video_files {
|
|
let filename_only = source_path
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.unwrap_or_default();
|
|
let parsed = crate::parser::parse(filename_only);
|
|
let Some(episode_number) = parsed.episode.or(parsed.absolute_episode) else {
|
|
outcome.episodes_unmatched += 1;
|
|
tracing::warn!(
|
|
file = filename_only,
|
|
"season pack: could not determine an episode number for this file, skipping"
|
|
);
|
|
continue;
|
|
};
|
|
// Prefer the individual file's own season marker when it has one
|
|
// (a pack can occasionally mix seasons); fall back to the pack's
|
|
// own season otherwise.
|
|
let file_season = parsed.season.unwrap_or(season_number);
|
|
|
|
let episode_id = match crate::scheduler::find_episode_id(
|
|
conn,
|
|
media_item_id,
|
|
file_season,
|
|
episode_number,
|
|
)? {
|
|
Some(id) => id,
|
|
None => {
|
|
outcome.episodes_unmatched += 1;
|
|
tracing::warn!(
|
|
file = filename_only,
|
|
season = file_season,
|
|
episode = episode_number,
|
|
"season pack: no tracked episode matches this file, skipping"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match import_season_pack_file(
|
|
conn,
|
|
release_id,
|
|
release_score,
|
|
media_item_id,
|
|
episode_id,
|
|
series_title,
|
|
file_season,
|
|
episode_number,
|
|
root_folder,
|
|
source_path,
|
|
) {
|
|
Ok(PackFileOutcome::Imported { quality_flagged }) => {
|
|
outcome.episodes_imported += 1;
|
|
if quality_flagged {
|
|
outcome.quality_flagged += 1;
|
|
}
|
|
}
|
|
Ok(PackFileOutcome::SkippedAlreadyHaveBetter) => {
|
|
outcome.episodes_already_had_better += 1;
|
|
}
|
|
Err(e) => {
|
|
outcome.episodes_unmatched += 1;
|
|
tracing::warn!(
|
|
file = filename_only,
|
|
error = %e,
|
|
"season pack: failed to import this file, continuing with the rest of the pack"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if outcome.episodes_imported > 0 {
|
|
conn.execute(
|
|
"UPDATE release SET status = 'imported' WHERE id = ?1",
|
|
params![release_id],
|
|
)?;
|
|
crate::db::record_event(
|
|
conn,
|
|
media_item_id,
|
|
None,
|
|
"imported",
|
|
&format!(
|
|
"season pack S{season_number:02}: {} imported, {} already had a better file, {} unmatched",
|
|
outcome.episodes_imported, outcome.episodes_already_had_better, outcome.episodes_unmatched
|
|
),
|
|
)?;
|
|
} else if outcome.episodes_already_had_better > 0 {
|
|
// Mirrors import_one's single-file "SkippedAlreadyHaveBetter" ->
|
|
// 'upgraded' handling: nothing new landed, but that's because the
|
|
// whole pack was a no-op upgrade attempt, not a failure.
|
|
conn.execute(
|
|
"UPDATE release SET status = 'upgraded' WHERE id = ?1",
|
|
params![release_id],
|
|
)?;
|
|
} else {
|
|
// Every file was unmatched and nothing was already-better either —
|
|
// genuinely wrong (mismatched season, mislabeled pack). Propagating
|
|
// this as an error routes it through the same retry/escalate-to-
|
|
// failed machinery `process_pending_grabs` already applies to a
|
|
// persistently failing single-file import.
|
|
anyhow::bail!(
|
|
"season pack import matched none of {} video file(s) to a tracked episode",
|
|
video_files.len()
|
|
);
|
|
}
|
|
|
|
Ok(outcome)
|
|
}
|
|
|
|
enum PackFileOutcome {
|
|
Imported { quality_flagged: bool },
|
|
SkippedAlreadyHaveBetter,
|
|
}
|
|
|
|
/// One file's worth of the season-pack import: the same dest-collision
|
|
/// scoring, free-space check, hardlink-or-copy, `episode_file` bookkeeping,
|
|
/// and post-import probe that `import_one` does for a single-episode grab,
|
|
/// scoped to one already-identified `episode_id` within a larger pack.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn import_season_pack_file(
|
|
conn: &Connection,
|
|
release_id: i64,
|
|
release_score: f32,
|
|
media_item_id: i64,
|
|
episode_id: i64,
|
|
series_title: &str,
|
|
season_number: u32,
|
|
episode_number: u32,
|
|
root_folder: &str,
|
|
source_path: &Path,
|
|
) -> Result<PackFileOutcome> {
|
|
let ext = source_path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("mkv")
|
|
.to_string();
|
|
let episode_title: Option<String> = conn.query_row(
|
|
"SELECT title FROM episode WHERE id = ?1",
|
|
params![episode_id],
|
|
|row| row.get(0),
|
|
)?;
|
|
let filename = deterministic_filename(
|
|
series_title,
|
|
season_number,
|
|
episode_number,
|
|
episode_title.as_deref(),
|
|
&ext,
|
|
);
|
|
let dest_dir = season_dir(root_folder, season_number);
|
|
std::fs::create_dir_all(&dest_dir)?;
|
|
let dest = dest_dir.join(&filename);
|
|
|
|
// Same reasoning as import_one's own dest-collision check: a second
|
|
// release for the same episode (here, from a *different* pack or a
|
|
// single-episode grab) must not silently overwrite a better file
|
|
// already in place.
|
|
let mut old_sibling: Option<PathBuf> = None;
|
|
if dest.exists() {
|
|
let existing_best: Option<f32> = conn.query_row(
|
|
"SELECT MAX(score) FROM release WHERE status = 'imported' AND id != ?1 AND episode_id = ?2",
|
|
params![release_id, episode_id],
|
|
|row| row.get(0),
|
|
)?;
|
|
if existing_best.is_some_and(|best| best >= release_score) {
|
|
return Ok(PackFileOutcome::SkippedAlreadyHaveBetter);
|
|
}
|
|
// This episode's file is being upgraded. Move the stale file aside
|
|
// to a `.old` sibling rather than deleting it (and its row) outright
|
|
// — `dest` is still free for the hardlink fast path, but the
|
|
// original survives on disk until the replacement is confirmed in
|
|
// place, so a failed free-space check or `link_or_copy_file` below
|
|
// can't lose the file. See import_one's matching comment for the
|
|
// fuller reasoning; this is the same fix applied there.
|
|
old_sibling = Some(PathBuf::from(format!("{}.old", dest.display())));
|
|
std::fs::rename(&dest, old_sibling.as_ref().unwrap())?;
|
|
}
|
|
|
|
let needed_bytes = std::fs::metadata(source_path)?.len();
|
|
if insufficient_space(&dest_dir, needed_bytes)? {
|
|
if let Some(old_sibling) = &old_sibling {
|
|
std::fs::rename(old_sibling, &dest).ok();
|
|
}
|
|
anyhow::bail!(
|
|
"not enough free space at {} for {needed_bytes} bytes (source: {})",
|
|
dest_dir.display(),
|
|
source_path.display()
|
|
);
|
|
}
|
|
|
|
if let Err(err) = link_or_copy_file(source_path, &dest) {
|
|
if let Some(old_sibling) = &old_sibling {
|
|
std::fs::rename(old_sibling, &dest).ok();
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
if let Some(old_sibling) = old_sibling {
|
|
conn.execute(
|
|
"DELETE FROM episode_file WHERE path = ?1",
|
|
params![dest.to_string_lossy()],
|
|
)?;
|
|
std::fs::remove_file(&old_sibling).ok();
|
|
}
|
|
|
|
let size_bytes = std::fs::metadata(&dest)?.len();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, ?4, 'none')",
|
|
params![episode_id, media_item_id, dest.to_string_lossy(), size_bytes],
|
|
)?;
|
|
let episode_file_id = conn.last_insert_rowid();
|
|
conn.execute(
|
|
"UPDATE episode SET has_file = 1 WHERE id = ?1",
|
|
params![episode_id],
|
|
)?;
|
|
|
|
let quality_flagged = match ensure_probed(conn, episode_file_id, &dest) {
|
|
Ok(_) => probe_indicates_import_problem(conn, episode_file_id)?,
|
|
Err(e) => {
|
|
tracing::warn!(episode_file_id, error = %e, "post-import probing failed");
|
|
false
|
|
}
|
|
};
|
|
|
|
Ok(PackFileOutcome::Imported { quality_flagged })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Generates a tiny real video via ffmpeg's `lavfi` synthetic source —
|
|
/// gives `ensure_probed`/`import_one` a real file to run actual ffprobe
|
|
/// against, rather than only exercising the DB/flag-computation logic
|
|
/// against hand-built fixtures.
|
|
fn generate_test_clip(dir: &Path, width: u32, height: u32) -> PathBuf {
|
|
let path = dir.join("clip.mkv");
|
|
let status = std::process::Command::new("ffmpeg")
|
|
.args(["-y", "-f", "lavfi", "-i"])
|
|
.arg(format!("testsrc=size={width}x{height}:duration=1:rate=1"))
|
|
.args(["-c:v", "libx264"])
|
|
.arg(&path)
|
|
.output()
|
|
.expect("failed to run ffmpeg to generate a test clip");
|
|
assert!(
|
|
status.status.success(),
|
|
"ffmpeg failed to generate test clip: {}",
|
|
String::from_utf8_lossy(&status.stderr)
|
|
);
|
|
path
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_probed_flags_a_low_resolution_file_as_under_quality() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-ensure-probed-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let clip = generate_test_clip(&dir, 640, 360);
|
|
|
|
let probed = ensure_probed(&conn, 1, &clip).unwrap();
|
|
assert!(probed, "first probe of a file should always run");
|
|
|
|
let (height, under_quality, status): (Option<i64>, i64, String) = conn
|
|
.query_row(
|
|
"SELECT height, flag_under_quality, corruption_status FROM media_file_probe WHERE episode_file_id = 1",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(height, Some(360));
|
|
assert_eq!(under_quality, 1);
|
|
assert_eq!(status, "probe_ok");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
/// A wider-than-16:9 master (2.00:1 is common for prestige/streaming
|
|
/// shows — verified live against a real "For All Mankind" episode at
|
|
/// exactly this resolution) has full 1920px width but under-1080
|
|
/// height purely from the aspect ratio, not from being a worse encode.
|
|
/// A height-only check flagged 49 real episodes of one show as
|
|
/// under-quality before this was caught — this file must NOT be
|
|
/// flagged, since its width clears the 1080p-class bar even though its
|
|
/// height doesn't.
|
|
#[test]
|
|
fn ensure_probed_does_not_flag_a_wide_aspect_ratio_file_as_under_quality() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-ensure-probed-wide-aspect-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let clip = generate_test_clip(&dir, 1920, 960);
|
|
|
|
ensure_probed(&conn, 1, &clip).unwrap();
|
|
|
|
let (width, height, under_quality): (Option<i64>, Option<i64>, i64) = conn
|
|
.query_row(
|
|
"SELECT width, height, flag_under_quality FROM media_file_probe WHERE episode_file_id = 1",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(width, Some(1920));
|
|
assert_eq!(height, Some(960));
|
|
assert_eq!(under_quality, 0);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_probed_skips_reprobing_an_unchanged_file() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-ensure-probed-skip-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let clip = generate_test_clip(&dir, 640, 360);
|
|
|
|
assert!(ensure_probed(&conn, 1, &clip).unwrap());
|
|
assert!(
|
|
!ensure_probed(&conn, 1, &clip).unwrap(),
|
|
"second probe against the exact same file content should be a no-op"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn probe_library_reports_probed_vs_skipped_up_to_date() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-probe-library-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let clip = generate_test_clip(&dir, 640, 360);
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, ?1, 4, 'none')",
|
|
params![clip.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
|
|
let first = probe_library(&conn).unwrap();
|
|
assert_eq!(first.probed, 1);
|
|
assert_eq!(first.skipped_up_to_date, 0);
|
|
|
|
let second = probe_library(&conn).unwrap();
|
|
assert_eq!(second.probed, 0);
|
|
assert_eq!(second.skipped_up_to_date, 1);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn verify_library_confirms_a_genuinely_decodable_file() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-verify-library-ok-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let clip = generate_test_clip(&dir, 640, 360);
|
|
conn.execute(
|
|
"UPDATE episode_file SET path = ?1 WHERE id = 1",
|
|
params![clip.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
// `ensure_probed` leaves a freshly-probed, never-decode-checked file
|
|
// at exactly the `corruption_status = 'probe_ok'` state
|
|
// `verify_library` targets.
|
|
ensure_probed(&conn, 1, &clip).unwrap();
|
|
|
|
let report = verify_library(&conn).unwrap();
|
|
assert_eq!(report.verified_ok, 1);
|
|
assert_eq!(report.corrupt, 0);
|
|
assert_eq!(report.errors, 0);
|
|
|
|
let status: String = conn
|
|
.query_row(
|
|
"SELECT corruption_status FROM media_file_probe WHERE episode_file_id = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(status, "decode_ok");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn verify_library_flags_a_file_that_parses_but_does_not_decode() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-verify-library-bad-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
// A file with an .mkv extension but garbage content: `ffprobe`'s
|
|
// cheap header check may or may not accept it, but the point here
|
|
// is simulating a row that's already `probe_ok` (however it got
|
|
// there) and confirming the full decode check catches what the
|
|
// header probe missed.
|
|
let bad_path = dir.join("bad.mkv");
|
|
std::fs::write(&bad_path, b"not a real matroska file").unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, ?1, 4, 'none')",
|
|
params![bad_path.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, corruption_status)
|
|
VALUES (1, datetime('now'), 4, 0, 'probe_ok')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let report = verify_library(&conn).unwrap();
|
|
assert_eq!(report.verified_ok, 0);
|
|
assert_eq!(report.corrupt, 1);
|
|
assert_eq!(report.errors, 0);
|
|
|
|
let status: String = conn
|
|
.query_row(
|
|
"SELECT corruption_status FROM media_file_probe WHERE episode_file_id = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(status, "decode_failed");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn verify_library_skips_files_that_never_even_passed_the_header_probe() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, '/tmp/nonexistent.mkv', 4, 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, corruption_status)
|
|
VALUES (1, datetime('now'), 4, 0, 'probe_failed')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let report = verify_library(&conn).unwrap();
|
|
assert_eq!(report.verified_ok, 0);
|
|
assert_eq!(report.corrupt, 0);
|
|
assert_eq!(report.errors, 0);
|
|
}
|
|
|
|
/// Seeds a movie release with `grabbed_at` shifted `hours_ago` into the
|
|
/// past, so stall/grace-period thresholds can be tested deterministically
|
|
/// instead of depending on real wall-clock time passing.
|
|
fn seeded_release_conn(hours_ago: f64) -> (Connection, i64) {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Some Movie 2016', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now', ?1))",
|
|
params![format!("-{hours_ago} hours")],
|
|
)
|
|
.unwrap();
|
|
(conn, 1)
|
|
}
|
|
|
|
/// A dedicated root_folder per row (rather than one shared dir with many
|
|
/// rows in it) so `RECONCILE_MIN_CLEAR_FLOOR` doesn't mask the specific
|
|
/// count each of these small tests is asserting on.
|
|
fn media_item_with_root(conn: &Connection, id: i64, root: &std::path::Path) {
|
|
std::fs::create_dir_all(root).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (?1, 'series', 'Some Show', 2020, 1, 1, ?2)",
|
|
params![id, root.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn reconcile_clears_episode_file_rows_whose_path_no_longer_exists() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
let dir = std::env::temp_dir().join(format!("breadarr-reconcile-{}", std::process::id()));
|
|
media_item_with_root(&conn, 1, &dir);
|
|
conn.execute(
|
|
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
|
|
VALUES (1, 1, 1, 1, 1, 1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let existing = dir.join("exists.mkv");
|
|
std::fs::write(&existing, b"data").unwrap();
|
|
let missing = dir.join("gone.mkv"); // deliberately never created, and
|
|
// no same-named file anywhere under `dir` either
|
|
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, 1, 1, ?1, 4, 'none')",
|
|
params![existing.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
// episode_id NULL simulates a movie's file going missing.
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (2, NULL, 1, ?1, 4, 'none')",
|
|
params![missing.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
|
|
let outcome = reconcile_missing_files(&conn, false).unwrap();
|
|
assert_eq!(outcome.cleared, 1);
|
|
assert_eq!(outcome.repaired, 0);
|
|
assert!(!outcome.aborted);
|
|
|
|
let remaining: i64 = conn
|
|
.query_row("SELECT count(*) FROM episode_file", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(remaining, 1, "only the still-existing file survives");
|
|
|
|
let has_file: i64 = conn
|
|
.query_row("SELECT has_file FROM episode WHERE id = 1", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(has_file, 1, "untouched — its own file still exists");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn reconcile_repairs_a_path_whose_show_folder_was_renamed_instead_of_clearing_it() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
let root =
|
|
std::env::temp_dir().join(format!("breadarr-reconcile-repair-{}", std::process::id()));
|
|
// Simulates the folder-normalization case: `root_folder` already
|
|
// reflects the renamed-to-include-year directory, but the stored
|
|
// `episode_file.path` still points at the old (pre-rename) name.
|
|
let renamed_show_dir = root.join("Black Adder (1983)").join("Season 1");
|
|
std::fs::create_dir_all(&renamed_show_dir).unwrap();
|
|
let real_file = renamed_show_dir.join("Blackadder - S01E04.mkv");
|
|
std::fs::write(&real_file, b"data").unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'series', 'Black Adder', 1983, 1, 1, ?1)",
|
|
params![root.join("Black Adder (1983)").to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
|
|
VALUES (1, 1, 1, 4, 1, 1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let stale_path = root
|
|
.join("Black Adder") // missing the "(1983)" suffix
|
|
.join("Season 1")
|
|
.join("Blackadder - S01E04.mkv");
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, 1, NULL, ?1, 4, 'none')",
|
|
params![stale_path.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
|
|
let outcome = reconcile_missing_files(&conn, false).unwrap();
|
|
assert_eq!(outcome.repaired, 1);
|
|
assert_eq!(outcome.cleared, 0);
|
|
assert!(!outcome.aborted);
|
|
|
|
let new_path: String = conn
|
|
.query_row("SELECT path FROM episode_file WHERE id = 1", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(new_path, real_file.to_string_lossy());
|
|
|
|
let has_file: i64 = conn
|
|
.query_row("SELECT has_file FROM episode WHERE id = 1", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(has_file, 1, "repaired, not cleared — file genuinely exists");
|
|
|
|
std::fs::remove_dir_all(&root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn reconcile_repairs_a_path_whose_extension_changed_from_a_transcode() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
let root = std::env::temp_dir().join(format!(
|
|
"breadarr-reconcile-transcode-{}",
|
|
std::process::id()
|
|
));
|
|
let movie_dir = root.join("Upgrade (2018)");
|
|
std::fs::create_dir_all(&movie_dir).unwrap();
|
|
// Simulates Tdarr re-encoding in place: the original .mp4 is gone,
|
|
// replaced by a .mkv with the same stem.
|
|
let real_file = movie_dir.join("Upgrade (2018).mkv");
|
|
std::fs::write(&real_file, b"data").unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Upgrade', 2018, 1, 1, ?1)",
|
|
params![movie_dir.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
let stale_path = movie_dir.join("Upgrade (2018).mp4");
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, ?1, 4, 'none')",
|
|
params![stale_path.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
|
|
let outcome = reconcile_missing_files(&conn, false).unwrap();
|
|
assert_eq!(outcome.repaired, 1);
|
|
assert_eq!(outcome.cleared, 0);
|
|
assert!(!outcome.aborted);
|
|
|
|
let new_path: String = conn
|
|
.query_row("SELECT path FROM episode_file WHERE id = 1", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(new_path, real_file.to_string_lossy());
|
|
|
|
std::fs::remove_dir_all(&root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn reconcile_refuses_to_clear_an_anomalous_fraction_in_one_pass() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
let root =
|
|
std::env::temp_dir().join(format!("breadarr-reconcile-breaker-{}", std::process::id()));
|
|
media_item_with_root(&conn, 1, &root);
|
|
|
|
// 20 rows, all genuinely missing (no matching basename anywhere) —
|
|
// comfortably past both the 10% fraction and the floor of 10.
|
|
for i in 0..20 {
|
|
conn.execute(
|
|
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
|
|
VALUES (?1, 1, 1, ?1, 1, 1)",
|
|
params![i],
|
|
)
|
|
.unwrap();
|
|
let path = root.join(format!("gone-{i}.mkv"));
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (?1, ?1, NULL, ?2, 4, 'none')",
|
|
params![i, path.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let outcome = reconcile_missing_files(&conn, false).unwrap();
|
|
assert!(outcome.aborted);
|
|
assert_eq!(outcome.cleared, 0);
|
|
assert_eq!(outcome.repaired, 0);
|
|
|
|
let remaining: i64 = conn
|
|
.query_row("SELECT count(*) FROM episode_file", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(remaining, 20, "nothing cleared once the breaker trips");
|
|
|
|
std::fs::remove_dir_all(&root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn reconcile_dry_run_reports_without_writing_anything() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-reconcile-dryrun-{}", std::process::id()));
|
|
media_item_with_root(&conn, 1, &dir);
|
|
let missing = dir.join("gone.mkv");
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, ?1, 4, 'none')",
|
|
params![missing.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
|
|
let outcome = reconcile_missing_files(&conn, true).unwrap();
|
|
assert_eq!(outcome.cleared, 1);
|
|
|
|
let remaining: i64 = conn
|
|
.query_row("SELECT count(*) FROM episode_file", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(remaining, 1, "dry run must not actually delete the row");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_fresh_grab_is_not_stalled() {
|
|
let (conn, release_id) = seeded_release_conn(1.0);
|
|
assert!(!grab_is_stalled(&conn, release_id).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn a_grab_untouched_past_the_threshold_is_stalled() {
|
|
let (conn, release_id) = seeded_release_conn(STALL_THRESHOLD_HOURS + 1.0);
|
|
assert!(grab_is_stalled(&conn, release_id).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn progress_advancing_resets_the_stall_clock() {
|
|
let (conn, release_id) = seeded_release_conn(STALL_THRESHOLD_HOURS + 1.0);
|
|
// grabbed_at is old, but progress was just observed advancing, so
|
|
// last_progress_at (which the stall check prefers) is fresh.
|
|
update_grab_progress(&conn, release_id, 0.5).unwrap();
|
|
assert!(!grab_is_stalled(&conn, release_id).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn update_grab_progress_ignores_a_non_increasing_value() {
|
|
let (conn, release_id) = seeded_release_conn(0.0);
|
|
update_grab_progress(&conn, release_id, 0.5).unwrap();
|
|
let first_seen: String = conn
|
|
.query_row(
|
|
"SELECT last_progress_at FROM release WHERE id = ?1",
|
|
params![release_id],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
// Same progress again shouldn't touch the watermark timestamp.
|
|
update_grab_progress(&conn, release_id, 0.5).unwrap();
|
|
let second_seen: String = conn
|
|
.query_row(
|
|
"SELECT last_progress_at FROM release WHERE id = ?1",
|
|
params![release_id],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(first_seen, second_seen);
|
|
}
|
|
|
|
#[test]
|
|
fn a_torrent_missing_within_the_grace_period_is_not_yet_failed() {
|
|
let (conn, release_id) = seeded_release_conn(0.0);
|
|
assert!(!grab_missing_past_grace(&conn, release_id).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn a_torrent_missing_past_the_grace_period_is_failed() {
|
|
let (conn, release_id) = seeded_release_conn(MISSING_GRACE_MINUTES / 60.0 + 1.0);
|
|
assert!(grab_missing_past_grace(&conn, release_id).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn fail_grab_reopens_the_release_for_search() {
|
|
let (conn, release_id) = seeded_release_conn(0.0);
|
|
let grab = PendingGrab::Movie {
|
|
release_id,
|
|
media_item_id: 1,
|
|
torrent_hash: "deadbeef".to_string(),
|
|
title: "Some Movie".to_string(),
|
|
year: Some(2016),
|
|
root_folder: "/tmp".to_string(),
|
|
};
|
|
fail_grab(&conn, &grab, "test").unwrap();
|
|
let status: String = conn
|
|
.query_row(
|
|
"SELECT status FROM release WHERE id = ?1",
|
|
params![release_id],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(status, "failed");
|
|
|
|
let (event_type, detail): (String, String) = conn
|
|
.query_row(
|
|
"SELECT event_type, detail FROM event_history WHERE media_item_id = 1",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(event_type, "failed");
|
|
assert_eq!(detail, "test");
|
|
}
|
|
|
|
#[test]
|
|
fn builds_filename_with_episode_title() {
|
|
assert_eq!(
|
|
deterministic_filename("Some Show", 4, 13, Some("Winter Gathering"), "mkv"),
|
|
"Some Show - S04E13 - Winter Gathering.mkv"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builds_filename_without_episode_title() {
|
|
assert_eq!(
|
|
deterministic_filename("Some Show", 1, 1, None, "mkv"),
|
|
"Some Show - S01E01.mkv"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitizes_path_hostile_characters() {
|
|
assert_eq!(sanitize("Kill: Ao / Blue?"), "Kill_ Ao _ Blue_");
|
|
}
|
|
|
|
#[test]
|
|
fn nearest_existing_ancestor_returns_the_path_itself_when_it_exists() {
|
|
let dir = std::env::temp_dir();
|
|
assert_eq!(nearest_existing_ancestor(&dir).unwrap(), dir);
|
|
}
|
|
|
|
#[test]
|
|
fn nearest_existing_ancestor_walks_up_past_nonexistent_components() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-ancestor-test-{}/does/not/exist/yet",
|
|
std::process::id()
|
|
));
|
|
let expected =
|
|
std::env::temp_dir().join(format!("breadarr-ancestor-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&expected).unwrap();
|
|
assert_eq!(nearest_existing_ancestor(&dir).unwrap(), expected);
|
|
std::fs::remove_dir_all(&expected).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn staging_dir_for_is_named_by_torrent_hash_under_the_marker_directory() {
|
|
let dest = std::env::temp_dir();
|
|
let staging = staging_dir_for(&dest, "deadbeef1234").unwrap();
|
|
assert_eq!(
|
|
staging.file_name().unwrap().to_str().unwrap(),
|
|
"deadbeef1234"
|
|
);
|
|
assert_eq!(
|
|
staging
|
|
.parent()
|
|
.unwrap()
|
|
.file_name()
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
STAGING_DIR_NAME
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn insufficient_space_is_false_for_a_trivially_small_request() {
|
|
assert!(!insufficient_space(&std::env::temp_dir(), 1).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn insufficient_space_is_true_for_an_absurd_request() {
|
|
// No real filesystem has an exabyte free.
|
|
assert!(insufficient_space(&std::env::temp_dir(), u64::MAX / 2).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn copy_via_temp_file_writes_through_a_part_file_and_renames_into_place() {
|
|
let dir = std::env::temp_dir().join(format!("breadarr-copy-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let src = dir.join("source.mkv");
|
|
std::fs::write(&src, b"fake video data").unwrap();
|
|
let dest = dir.join("dest.mkv");
|
|
|
|
copy_via_temp_file(&src, &dest).unwrap();
|
|
|
|
assert!(dest.exists());
|
|
assert!(
|
|
!dir.join("dest.mkv.part").exists(),
|
|
"the .part file should be renamed away, not left behind"
|
|
);
|
|
assert!(src.exists(), "the copy fallback should also preserve src");
|
|
assert_eq!(std::fs::read(&src).unwrap(), std::fs::read(&dest).unwrap());
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn link_or_copy_file_leaves_the_source_in_place() {
|
|
let dir = std::env::temp_dir().join(format!("breadarr-link-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let src = dir.join("source.mkv");
|
|
std::fs::write(&src, b"fake video data").unwrap();
|
|
let dest = dir.join("dest.mkv");
|
|
|
|
link_or_copy_file(&src, &dest).unwrap();
|
|
|
|
assert!(src.exists(), "source should survive a hardlink-or-copy");
|
|
assert!(dest.exists());
|
|
assert_eq!(std::fs::read(&src).unwrap(), std::fs::read(&dest).unwrap());
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn locates_a_single_file_torrent() {
|
|
let dir = std::env::temp_dir().join(format!("breadarr-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let file = dir.join("episode.mkv");
|
|
std::fs::write(&file, b"fake video data").unwrap();
|
|
|
|
let found = locate_video_file(&file).unwrap();
|
|
assert_eq!(found, file);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn remap_path_translates_container_prefix_to_host_prefix() {
|
|
assert_eq!(
|
|
remap_path(
|
|
"/downloads/Big Buck Bunny",
|
|
"/downloads",
|
|
"/home/breadway/downloads"
|
|
),
|
|
PathBuf::from("/home/breadway/downloads/Big Buck Bunny")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remap_path_is_noop_when_prefixes_not_configured() {
|
|
assert_eq!(
|
|
remap_path("/downloads/Big Buck Bunny", "", ""),
|
|
PathBuf::from("/downloads/Big Buck Bunny")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_pending_grabs_routes_each_grab_by_torrent_state() {
|
|
use crate::qbit::TorrentInfo;
|
|
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-process-grabs-{}", std::process::id()));
|
|
let dest_root = dir.join("library");
|
|
std::fs::create_dir_all(&dest_root).unwrap();
|
|
|
|
// Grab 1: complete, should import.
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Complete Movie', 2020, 1, 1, ?1)",
|
|
params![dest_root.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Complete Movie 2020', 1, 'guid-1', 'grabbed', 'hash-complete', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let complete_content = dir.join("complete-content");
|
|
std::fs::create_dir_all(&complete_content).unwrap();
|
|
std::fs::write(complete_content.join("movie.mp4"), b"data").unwrap();
|
|
|
|
// Grab 2: still downloading, should be skipped as incomplete.
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (2, 'movie', 'Downloading Movie', 2020, 1, 1, ?1)",
|
|
params![dest_root.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (2, 2, NULL, 'Downloading Movie 2020', 1, 'guid-2', 'grabbed', 'hash-downloading', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Grab 3: torrent hash absent from qBit's list, but well within the
|
|
// grace period — should not yet be failed.
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (3, 'movie', 'Fresh Grab', 2020, 1, 1, ?1)",
|
|
params![dest_root.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (3, 3, NULL, 'Fresh Grab 2020', 1, 'guid-3', 'grabbed', 'hash-missing-fresh', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Grab 4: torrent hash absent from qBit's list, grabbed long ago —
|
|
// should be marked failed.
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (4, 'movie', 'Abandoned Grab', 2020, 1, 1, ?1)",
|
|
params![dest_root.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (4, 4, NULL, 'Abandoned Grab 2020', 1, 'guid-4', 'grabbed', 'hash-missing-stale', datetime('now', '-1 hour'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let pending = fetch_pending_grabs(&conn).unwrap();
|
|
assert_eq!(pending.len(), 4);
|
|
|
|
let torrents = vec![
|
|
TorrentInfo {
|
|
hash: "hash-complete".to_string(),
|
|
name: "complete".to_string(),
|
|
state: "uploading".to_string(),
|
|
progress: 1.0,
|
|
save_path: dir.to_string_lossy().to_string(),
|
|
content_path: complete_content.to_string_lossy().to_string(),
|
|
},
|
|
TorrentInfo {
|
|
hash: "hash-downloading".to_string(),
|
|
name: "downloading".to_string(),
|
|
state: "downloading".to_string(),
|
|
progress: 0.4,
|
|
save_path: dir.to_string_lossy().to_string(),
|
|
content_path: dir
|
|
.join("downloading-content")
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
},
|
|
// hash-missing-fresh and hash-missing-stale are deliberately
|
|
// absent — simulating torrents qBit no longer knows about.
|
|
];
|
|
|
|
let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap();
|
|
|
|
assert_eq!(stats.imported, 1);
|
|
assert_eq!(stats.skipped_incomplete, 1);
|
|
assert_eq!(stats.failed, 1);
|
|
assert_eq!(stats.errors, 0);
|
|
|
|
let statuses: Vec<(i64, String)> = {
|
|
let mut stmt = conn
|
|
.prepare("SELECT id, status FROM release ORDER BY id")
|
|
.unwrap();
|
|
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
|
.unwrap()
|
|
.collect::<rusqlite::Result<_>>()
|
|
.unwrap()
|
|
};
|
|
assert_eq!(statuses[0], (1, "imported".to_string()));
|
|
assert_eq!(statuses[1], (2, "grabbed".to_string()));
|
|
assert_eq!(statuses[2], (3, "grabbed".to_string()));
|
|
assert_eq!(statuses[3], (4, "failed".to_string()));
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_import_a_torrent_while_it_is_still_being_physically_moved() {
|
|
use crate::qbit::TorrentInfo;
|
|
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Moving Movie', 2020, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Moving Movie 2020', 1, 'guid-1', 'grabbed', 'hash-moving', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let pending = fetch_pending_grabs(&conn).unwrap();
|
|
let torrents = vec![TorrentInfo {
|
|
hash: "hash-moving".to_string(),
|
|
name: "moving".to_string(),
|
|
// qBittorrent's own state for "setLocation relocation (or a
|
|
// manual move) still physically in flight" — content_path may
|
|
// already point at the destination while the bytes aren't
|
|
// fully there yet.
|
|
state: "moving".to_string(),
|
|
progress: 1.0,
|
|
save_path: "/tmp".to_string(),
|
|
content_path: "/tmp/somewhere-mid-move".to_string(),
|
|
}];
|
|
|
|
let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap();
|
|
assert_eq!(stats.imported, 0);
|
|
assert_eq!(stats.skipped_incomplete, 1);
|
|
assert_eq!(stats.errors, 0);
|
|
assert_eq!(stats.failed, 0);
|
|
|
|
let status: String = conn
|
|
.query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(status, "grabbed");
|
|
}
|
|
|
|
#[test]
|
|
fn a_persistently_failing_import_escalates_to_failed_instead_of_retrying_forever() {
|
|
use crate::qbit::TorrentInfo;
|
|
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Broken Movie', 2020, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Broken Movie 2020', 1, 'guid-1', 'grabbed', 'hash-broken', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-persistent-import-error-{}",
|
|
std::process::id()
|
|
));
|
|
// Deliberately empty — `locate_video_file` finds no video file
|
|
// here, so `import_one` fails deterministically every time,
|
|
// simulating a persistent real-world error (bad path remap,
|
|
// unreadable file) without needing to fake one out.
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
let torrents = vec![TorrentInfo {
|
|
hash: "hash-broken".to_string(),
|
|
name: "broken".to_string(),
|
|
state: "uploading".to_string(),
|
|
progress: 1.0,
|
|
save_path: dir.to_string_lossy().to_string(),
|
|
content_path: dir.to_string_lossy().to_string(),
|
|
}];
|
|
|
|
for i in 1..MAX_IMPORT_ERRORS {
|
|
let pending = fetch_pending_grabs(&conn).unwrap();
|
|
let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap();
|
|
assert_eq!(stats.errors, 1, "iteration {i}");
|
|
assert_eq!(stats.failed, 0, "iteration {i}");
|
|
let status: String = conn
|
|
.query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(status, "grabbed", "iteration {i}");
|
|
}
|
|
|
|
// The Nth failure crosses the threshold and gives up.
|
|
let pending = fetch_pending_grabs(&conn).unwrap();
|
|
let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap();
|
|
assert_eq!(stats.failed, 1);
|
|
assert_eq!(stats.errors, 0);
|
|
let status: String = conn
|
|
.query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(status, "failed");
|
|
|
|
// Failed releases are excluded from `fetch_pending_grabs`, so it
|
|
// stops being retried at all.
|
|
assert!(fetch_pending_grabs(&conn).unwrap().is_empty());
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn imports_a_movie_release_with_no_episode_id() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Some Movie 2016 1080p', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-movie-import-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let dest_root = dir.join("library");
|
|
std::fs::create_dir_all(&dest_root).unwrap();
|
|
let content = dir.join("Some.Movie.2016.1080p.mp4");
|
|
std::fs::write(&content, b"fake movie data").unwrap();
|
|
|
|
let grab = PendingGrab::Movie {
|
|
release_id: 1,
|
|
media_item_id: 1,
|
|
torrent_hash: "deadbeef".to_string(),
|
|
title: "Some Movie".to_string(),
|
|
year: Some(2016),
|
|
root_folder: dest_root.to_string_lossy().to_string(),
|
|
};
|
|
|
|
let outcome = import_one(&conn, &grab, &content).unwrap();
|
|
let ImportOutcome::Imported { remuxed, .. } = outcome else {
|
|
panic!("expected a real import, got a skip");
|
|
};
|
|
assert!(!remuxed);
|
|
|
|
let dest = dest_root.join("Some Movie (2016).mp4");
|
|
assert!(dest.exists(), "expected {} to exist", dest.display());
|
|
assert!(
|
|
content.exists(),
|
|
"source file should survive import so qBittorrent can keep seeding"
|
|
);
|
|
|
|
let status: String = conn
|
|
.query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(status, "imported");
|
|
|
|
let (episode_id, media_item_id): (Option<i64>, Option<i64>) = conn
|
|
.query_row(
|
|
"SELECT episode_id, media_item_id FROM episode_file WHERE path = ?1",
|
|
params![dest.to_string_lossy()],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(episode_id, None);
|
|
assert_eq!(media_item_id, Some(1));
|
|
|
|
let event_type: String = conn
|
|
.query_row(
|
|
"SELECT event_type FROM event_history WHERE media_item_id = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(event_type, "imported");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn import_one_places_a_single_episode_grab_under_its_season_subfolder() {
|
|
// Regression: a real single-episode grab (Mushoku Tensei S03E02/E03,
|
|
// not part of a season pack) landed flat in the show's root_folder
|
|
// instead of alongside the rest of the season's already-organized
|
|
// files in `Season 03/`, once the pre-existing library-scanned
|
|
// structure had nothing left to mask it.
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'series', 'Some Show', 2021, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
|
|
VALUES (1, 1, 3, 2, 1, 0)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, 1, 'Some Show S03E02 1080p', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-single-episode-season-dir-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let dest_root = dir.join("library");
|
|
std::fs::create_dir_all(&dest_root).unwrap();
|
|
let content = dir.join("Some.Show.S03E02.1080p.mp4");
|
|
std::fs::write(&content, b"fake episode data").unwrap();
|
|
|
|
let grab = PendingGrab::Episode {
|
|
release_id: 1,
|
|
episode_id: 1,
|
|
media_item_id: 1,
|
|
torrent_hash: "deadbeef".to_string(),
|
|
series_title: "Some Show".to_string(),
|
|
season_number: 3,
|
|
episode_number: 2,
|
|
episode_title: None,
|
|
root_folder: dest_root.to_string_lossy().to_string(),
|
|
};
|
|
|
|
let outcome = import_one(&conn, &grab, &content).unwrap();
|
|
assert!(matches!(outcome, ImportOutcome::Imported { .. }));
|
|
|
|
let dest = dest_root.join("Season 03").join("Some Show - S03E02.mp4");
|
|
assert!(
|
|
dest.exists(),
|
|
"expected {} to exist under the season subfolder",
|
|
dest.display()
|
|
);
|
|
assert!(
|
|
!dest_root.join("Some Show - S03E02.mp4").exists(),
|
|
"must not also land flat in the show's root folder"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn import_one_flags_quality_when_the_real_file_is_under_1080p() {
|
|
// Mirrors a real production case: a release whose title carries no
|
|
// resolution tag at all turned out, once ffprobed, to actually be
|
|
// SD — this is the exact gap the post-import ground-truth check
|
|
// closes (the pipeline previously only ever trusted claimed title
|
|
// text, never the real downloaded bytes).
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Some.Movie.WEB.H264-GROUP', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-quality-flag-import-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let dest_root = dir.join("library");
|
|
std::fs::create_dir_all(&dest_root).unwrap();
|
|
let generated = generate_test_clip(&dir, 640, 360);
|
|
let content = dir.join("Some.Movie.WEB.H264-GROUP.mkv");
|
|
std::fs::rename(&generated, &content).unwrap();
|
|
|
|
let grab = PendingGrab::Movie {
|
|
release_id: 1,
|
|
media_item_id: 1,
|
|
torrent_hash: "deadbeef".to_string(),
|
|
title: "Some Movie".to_string(),
|
|
year: Some(2016),
|
|
root_folder: dest_root.to_string_lossy().to_string(),
|
|
};
|
|
|
|
let outcome = import_one(&conn, &grab, &content).unwrap();
|
|
let ImportOutcome::Imported {
|
|
quality_flagged, ..
|
|
} = outcome
|
|
else {
|
|
panic!("expected a real import, got a skip");
|
|
};
|
|
assert!(
|
|
quality_flagged,
|
|
"a genuinely sub-1080p file should be flagged even with an untagged title"
|
|
);
|
|
|
|
let flagged: i64 = conn
|
|
.query_row(
|
|
"SELECT flag_under_quality FROM media_file_probe WHERE episode_file_id = (SELECT id FROM episode_file LIMIT 1)",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(flagged, 1);
|
|
|
|
let quality_events: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM event_history WHERE media_item_id = 1 AND detail LIKE 'quality concern%'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(quality_events, 1);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
/// Generates a real mkv with two audio tracks — a non-English one
|
|
/// flagged as default (track order 0), and English not-default (track
|
|
/// order 1) — so `remux_backlog` has a genuine "Italian track 1,
|
|
/// English track 2" case to fix, not just a hand-built fixture.
|
|
fn generate_dual_audio_clip(dir: &Path) -> PathBuf {
|
|
let path = dir.join("dual-audio.mkv");
|
|
let status = std::process::Command::new("ffmpeg")
|
|
.args([
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc=size=1920x1080:duration=1:rate=1",
|
|
])
|
|
.args(["-f", "lavfi", "-i", "sine=frequency=440:duration=1"])
|
|
.args(["-f", "lavfi", "-i", "sine=frequency=880:duration=1"])
|
|
.args(["-map", "0:v", "-map", "1:a", "-map", "2:a"])
|
|
.args([
|
|
"-metadata:s:a:0",
|
|
"language=ita",
|
|
"-disposition:a:0",
|
|
"default",
|
|
])
|
|
.args(["-metadata:s:a:1", "language=eng", "-disposition:a:1", "0"])
|
|
.args(["-c:v", "libx264", "-c:a", "aac"])
|
|
.arg(&path)
|
|
.output()
|
|
.expect("failed to run ffmpeg to generate a dual-audio test clip");
|
|
assert!(
|
|
status.status.success(),
|
|
"ffmpeg failed to generate dual-audio clip: {}",
|
|
String::from_utf8_lossy(&status.stderr)
|
|
);
|
|
path
|
|
}
|
|
|
|
#[test]
|
|
fn remux_backlog_promotes_english_to_default_for_a_flagged_file() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-remux-backlog-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let clip = generate_dual_audio_clip(&dir);
|
|
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, ?1, 4, 'none')",
|
|
params![clip.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
// Probe it first so the flag actually gets set from real ffprobe
|
|
// output, exactly like it would in the running daemon.
|
|
ensure_probed(&conn, 1, &clip).unwrap();
|
|
let flagged_before: i64 = conn
|
|
.query_row(
|
|
"SELECT flag_non_english_default_audio FROM media_file_probe WHERE episode_file_id = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(flagged_before, 1, "fixture clip should start flagged");
|
|
|
|
let report = remux_backlog(&conn).unwrap();
|
|
assert_eq!(report.remuxed, 1);
|
|
assert_eq!(report.errors, 0);
|
|
|
|
let tracks = mkv::inspect_audio_tracks(&clip).unwrap();
|
|
assert!(
|
|
mkv::default_track_is_english_or_unset(&tracks),
|
|
"the remuxed file's default audio track should now be English"
|
|
);
|
|
|
|
let flagged_after: i64 = conn
|
|
.query_row(
|
|
"SELECT flag_non_english_default_audio FROM media_file_probe WHERE episode_file_id = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
flagged_after, 0,
|
|
"re-probe after remux should clear the flag"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn remux_backlog_skips_non_mkv_files() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, '/tmp/some-movie.mp4', 4, 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime,
|
|
corruption_status, flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio)
|
|
VALUES (1, datetime('now'), 4, 0, 'probe_ok', 0, 0, 0, 1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let report = remux_backlog(&conn).unwrap();
|
|
assert_eq!(report.remuxed, 0);
|
|
assert_eq!(report.skipped_not_mkv, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn refuses_to_overwrite_an_already_imported_higher_scoring_file() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
// The winner: already imported, high score.
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Some Movie 2016 1080p', 1, 'guid-1', 20.0, 'imported', 'aaaa', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
// The loser: a second, lower-scoring release for the same movie,
|
|
// now sitting completed in qBittorrent and up for import.
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
|
|
VALUES (2, 1, NULL, 'Some Movie 2016 480p', 1, 'guid-2', 5.0, 'grabbed', 'bbbb', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-collision-import-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let dest_root = dir.join("library");
|
|
std::fs::create_dir_all(&dest_root).unwrap();
|
|
|
|
// The winner's file already sits at the deterministic destination.
|
|
let dest = dest_root.join("Some Movie (2016).mp4");
|
|
std::fs::write(&dest, b"the better file, already imported").unwrap();
|
|
|
|
let content = dir.join("Some.Movie.2016.480p.mp4");
|
|
std::fs::write(&content, b"a worse duplicate arriving late").unwrap();
|
|
|
|
let grab = PendingGrab::Movie {
|
|
release_id: 2,
|
|
media_item_id: 1,
|
|
torrent_hash: "bbbb".to_string(),
|
|
title: "Some Movie".to_string(),
|
|
year: Some(2016),
|
|
root_folder: dest_root.to_string_lossy().to_string(),
|
|
};
|
|
|
|
let outcome = import_one(&conn, &grab, &content).unwrap();
|
|
assert!(matches!(outcome, ImportOutcome::SkippedAlreadyHaveBetter));
|
|
|
|
// The existing better file must be untouched, not overwritten.
|
|
assert_eq!(
|
|
std::fs::read(&dest).unwrap(),
|
|
b"the better file, already imported"
|
|
);
|
|
|
|
let status: String = conn
|
|
.query_row("SELECT status FROM release WHERE id = 2", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(status, "upgraded");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_strictly_better_release_replaces_the_existing_file_via_a_real_hardlink_swap() {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
// The old, worse release: already imported, low score.
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 'Some Movie 2016 480p', 1, 'guid-1', 5.0, 'imported', 'aaaa', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
// The new, better release: sitting completed in qBittorrent, up for import.
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
|
|
VALUES (2, 1, NULL, 'Some Movie 2016 1080p', 1, 'guid-2', 20.0, 'grabbed', 'bbbb', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let dir =
|
|
std::env::temp_dir().join(format!("breadarr-upgrade-swap-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let dest_root = dir.join("library");
|
|
std::fs::create_dir_all(&dest_root).unwrap();
|
|
|
|
// The old, worse file already sits at the deterministic destination,
|
|
// tracked by its own episode_file row.
|
|
let dest = dest_root.join("Some Movie (2016).mp4");
|
|
std::fs::write(&dest, b"the old, worse file").unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, 1, ?1, 20, 'none')",
|
|
params![dest.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
|
|
let content = dir.join("Some.Movie.2016.1080p.mp4");
|
|
std::fs::write(&content, b"the new, better file").unwrap();
|
|
|
|
let grab = PendingGrab::Movie {
|
|
release_id: 2,
|
|
media_item_id: 1,
|
|
torrent_hash: "bbbb".to_string(),
|
|
title: "Some Movie".to_string(),
|
|
year: Some(2016),
|
|
root_folder: dest_root.to_string_lossy().to_string(),
|
|
};
|
|
|
|
let outcome = import_one(&conn, &grab, &content).unwrap();
|
|
assert!(matches!(outcome, ImportOutcome::Imported { .. }));
|
|
|
|
// The new file's content landed at the shared deterministic path.
|
|
assert_eq!(std::fs::read(&dest).unwrap(), b"the new, better file");
|
|
// It's a genuine hardlink to the source (same inode), not a copy —
|
|
// confirms the old file/row was cleared first so `hard_link`
|
|
// itself succeeded instead of falling back to `copy_via_temp_file`.
|
|
assert_eq!(
|
|
std::fs::metadata(&dest).unwrap().ino(),
|
|
std::fs::metadata(&content).unwrap().ino()
|
|
);
|
|
|
|
// Exactly one episode_file row survives for this movie — the old
|
|
// one was removed, not left behind as a duplicate alongside the new.
|
|
let file_count: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM episode_file WHERE media_item_id = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(file_count, 1);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn locates_the_largest_video_file_in_a_directory() {
|
|
let dir = std::env::temp_dir().join(format!("breadarr-test-dir-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("sample.mkv"), vec![0u8; 100]).unwrap();
|
|
std::fs::write(dir.join("episode.mkv"), vec![0u8; 10_000]).unwrap();
|
|
std::fs::write(dir.join("readme.txt"), b"not a video").unwrap();
|
|
|
|
let found = locate_video_file(&dir).unwrap();
|
|
assert_eq!(found.file_name().unwrap(), "episode.mkv");
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
/// Seeds a series with `episode_count` episodes in season 1, all
|
|
/// monitored and missing, plus a `grabbed` season-pack release row.
|
|
/// Returns the media_item_id (always 1) and release_id (always 1).
|
|
fn seeded_season_pack_conn(episode_count: u32) -> Connection {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
crate::db::init(&conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
|
|
VALUES (1, 'series', 'Some Show', 2020, 1, 1, '/tmp')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for ep in 1..=episode_count {
|
|
conn.execute(
|
|
"INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file)
|
|
VALUES (1, 1, ?1, 1, 0)",
|
|
params![ep],
|
|
)
|
|
.unwrap();
|
|
}
|
|
conn.execute(
|
|
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, season_number, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
|
|
VALUES (1, 1, NULL, 1, 'Some Show S01 Complete 1080p', 1, 'guid-1', 15.0, 'grabbed', 'aaaa', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn
|
|
}
|
|
|
|
#[test]
|
|
fn import_season_pack_imports_every_file_and_marks_episodes_owned() {
|
|
let conn = seeded_season_pack_conn(3);
|
|
let dir = std::env::temp_dir().join(format!("breadarr-season-pack-{}", std::process::id()));
|
|
let pack_dir = dir.join("Some.Show.S01.1080p.WEB-DL");
|
|
std::fs::create_dir_all(&pack_dir).unwrap();
|
|
for ep in 1..=3u32 {
|
|
std::fs::write(
|
|
pack_dir.join(format!("Some.Show.S01E{ep:02}.1080p.WEB-DL.mkv")),
|
|
format!("episode {ep} content"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let dest_root = dir.join("library");
|
|
|
|
let outcome = import_season_pack(
|
|
&conn,
|
|
1,
|
|
1,
|
|
"Some Show",
|
|
1,
|
|
&dest_root.to_string_lossy(),
|
|
&pack_dir,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.episodes_imported, 3);
|
|
assert_eq!(outcome.episodes_already_had_better, 0);
|
|
assert_eq!(outcome.episodes_unmatched, 0);
|
|
|
|
let has_file_count: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM episode WHERE media_item_id = 1 AND has_file = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(has_file_count, 3);
|
|
|
|
let status: String = conn
|
|
.query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(status, "imported");
|
|
|
|
assert!(dest_root
|
|
.join("Season 01")
|
|
.join("Some Show - S01E01.mkv")
|
|
.exists());
|
|
assert!(dest_root
|
|
.join("Season 01")
|
|
.join("Some Show - S01E02.mkv")
|
|
.exists());
|
|
assert!(dest_root
|
|
.join("Season 01")
|
|
.join("Some Show - S01E03.mkv")
|
|
.exists());
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn import_season_pack_skips_episodes_that_already_have_a_better_file() {
|
|
let conn = seeded_season_pack_conn(2);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-season-pack-partial-{}",
|
|
std::process::id()
|
|
));
|
|
let pack_dir = dir.join("pack");
|
|
std::fs::create_dir_all(&pack_dir).unwrap();
|
|
std::fs::write(pack_dir.join("Show.S01E01.mkv"), b"new e01").unwrap();
|
|
std::fs::write(pack_dir.join("Show.S01E02.mkv"), b"new e02").unwrap();
|
|
let dest_root = dir.join("library");
|
|
let season_dir = dest_root.join("Season 01");
|
|
std::fs::create_dir_all(&season_dir).unwrap();
|
|
|
|
// Episode 1 already has a higher-scoring imported release.
|
|
conn.execute(
|
|
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
|
|
VALUES (2, 1, 1, 'Some Show S01E01 1080p REMUX', 1, 'guid-2', 50.0, 'imported', 'bbbb', datetime('now'))",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let existing = season_dir.join("Some Show - S01E01.mkv");
|
|
std::fs::write(&existing, b"already-better e01").unwrap();
|
|
conn.execute(
|
|
"INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status)
|
|
VALUES (1, NULL, ?1, 19, 'none')",
|
|
params![existing.to_string_lossy()],
|
|
)
|
|
.unwrap();
|
|
|
|
let outcome = import_season_pack(
|
|
&conn,
|
|
1,
|
|
1,
|
|
"Some Show",
|
|
1,
|
|
&dest_root.to_string_lossy(),
|
|
&pack_dir,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.episodes_imported, 1); // only episode 2
|
|
assert_eq!(outcome.episodes_already_had_better, 1); // episode 1 skipped
|
|
assert_eq!(
|
|
std::fs::read(&existing).unwrap(),
|
|
b"already-better e01",
|
|
"episode 1's better file must survive untouched"
|
|
);
|
|
assert!(season_dir.join("Some Show - S01E02.mkv").exists());
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn import_season_pack_fails_when_nothing_in_it_matches_a_tracked_episode() {
|
|
let conn = seeded_season_pack_conn(2);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadarr-season-pack-nomatch-{}",
|
|
std::process::id()
|
|
));
|
|
let pack_dir = dir.join("pack");
|
|
std::fs::create_dir_all(&pack_dir).unwrap();
|
|
// Wrong season entirely — nothing here matches season 1's episodes.
|
|
std::fs::write(pack_dir.join("Show.S02E01.mkv"), b"wrong season").unwrap();
|
|
std::fs::write(pack_dir.join("Show.S02E02.mkv"), b"wrong season").unwrap();
|
|
let dest_root = dir.join("library");
|
|
|
|
let result = import_season_pack(
|
|
&conn,
|
|
1,
|
|
1,
|
|
"Some Show",
|
|
1,
|
|
&dest_root.to_string_lossy(),
|
|
&pack_dir,
|
|
);
|
|
assert!(result.is_err());
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
}
|