feat: season-pack vs per-episode grab selection (#94)
ci / web (push) Successful in 24s
e2e / e2e (push) Successful in 54s
ci / rust (push) Successful in 2m11s

This commit was merged in pull request #94.
This commit is contained in:
2026-08-23 01:40:53 +01:00
parent 35391f02fc
commit 9f812fac11
28 changed files with 2855 additions and 143 deletions
+821 -26
View File
@@ -182,6 +182,18 @@ impl ImportAction {
),
}
}
for pending in pending_tv_imports(database).await? {
match self.import_tv_one(database, &pending).await {
Ok(Some(outcome)) => outcomes.push(outcome),
Ok(None) => {}
Err(error) => tracing::error!(
grab_id = pending.grab_id,
series = pending.series_title,
%error,
"tv import failed"
),
}
}
Ok(outcomes)
}
@@ -204,34 +216,12 @@ impl ImportAction {
};
let original_language = arr_db::policy::language(original_language);
let Some(content) = self.transmission.torrent_content(&pending.infohash).await? else {
// Gone from Transmission. Whether that is a failure or a manual
// removal is issue #86's call; leave the grab alone.
tracing::warn!(
grab_id = pending.grab_id,
infohash = pending.infohash,
"downloaded grab has no torrent in Transmission; not importing"
);
let Some(paths) = self
.torrent_paths(pending.grab_id, &pending.infohash)
.await?
else {
return Ok(None);
};
// Torrent-declared names are untrusted input: an absolute or
// `..`-carrying entry would escape the download root and get probed —
// and possibly hardlinked — from anywhere on disk.
let paths: Vec<PathBuf> = content
.files
.iter()
.filter_map(|file| {
let path = safe_join(&content.download_dir, &file.path);
if path.is_none() {
tracing::warn!(
grab_id = pending.grab_id,
path = %file.path.display(),
"torrent file path escapes the download root; skipping"
);
}
path
})
.collect();
// No expected runtime yet: the movies table carries no TMDB runtime,
// so feature selection is by size alone (largest readable video).
@@ -308,6 +298,289 @@ impl ImportAction {
)))
}
/// The torrent's files as safe local paths, or `None` when Transmission
/// no longer has the torrent.
///
/// Torrent-declared names are untrusted input: an absolute or
/// `..`-carrying entry would escape the download root and get probed —
/// and possibly hardlinked — from anywhere on disk.
async fn torrent_paths(
&self,
grab_id: i64,
infohash: &str,
) -> Result<Option<Vec<PathBuf>>, ImportError> {
let Some(content) = self.transmission.torrent_content(infohash).await? else {
// Gone from Transmission. Whether that is a failure or a manual
// removal is issue #86's call; leave the grab alone.
tracing::warn!(
grab_id,
infohash,
"downloaded grab has no torrent in Transmission; not importing"
);
return Ok(None);
};
Ok(Some(
content
.files
.iter()
.filter_map(|file| {
let path = safe_join(&content.download_dir, &file.path);
if path.is_none() {
tracing::warn!(
grab_id,
path = %file.path.display(),
"torrent file path escapes the download root; skipping"
);
}
path
})
.collect(),
))
}
/// Import one downloaded TV grab: a single episode or a season pack.
///
/// A pack maps each video file to an episode by the `SxxEyy` tag in its
/// own name, then imports the episodes that are missing. Episodes already
/// on disk are skipped, never re-imported. If any mapped file fails the
/// policy hard, the whole pack hard-fails: that release is blacklisted
/// and the episodes reopen as gaps, which the grab selection then fills
/// per episode rather than writing the season off.
async fn import_tv_one(
&self,
database: &Db,
pending: &PendingTvImport,
) -> Result<Option<Outcome>, ImportError> {
// §5.2: no original language, nothing to judge audio against.
let Some(original_language) = pending.original_language.as_deref() else {
tracing::warn!(
series = pending.series_title,
"no original language yet; not importing"
);
return Ok(None);
};
let original_language = arr_db::policy::language(original_language);
let episodes = target_episodes(database, pending).await?;
let Some(first) = episodes.first() else {
return Ok(None);
};
let Some(loaded) = database.episode_policy(first.id).await? else {
return Ok(None);
};
let Some(paths) = self
.torrent_paths(pending.grab_id, &pending.infohash)
.await?
else {
return Ok(None);
};
let candidates = self.probe_all(&paths).await?;
let assignments = assign_files(pending, &episodes, candidates);
if assignments.is_empty() {
self.forget_probes(&paths).await;
return self
.hard_fail_tv(database, pending, "no file matches a wanted episode")
.await
.map(Some);
}
// §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns
// the whole release (§5.7), not the episodes.
let mut imports = Vec::new();
for assignment in assignments {
if assignment.episode.has_file {
// The partial-overlap case: this episode exists on disk and
// is not re-imported, whatever the pack carries for it.
tracing::info!(
grab_id = pending.grab_id,
episode_id = assignment.episode.id,
"episode already on disk; skipping its file in the pack"
);
continue;
}
let evaluation = evaluate(
&loaded.policy,
&loaded.overrides,
&original_language,
Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size),
);
let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => {
self.forget_probes(&paths).await;
return self
.hard_fail_tv(database, pending, &rule.name())
.await
.map(Some);
}
Verdict::Waived(rule) => Some(rule),
Verdict::Eligible => None,
};
imports.push((assignment, waiver));
}
if imports.is_empty() {
// Everything the pack holds is already on disk. Nothing to
// place; the grab is settled.
sqlx::query!(
"UPDATE grabs
SET state = 'imported',
imported_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
pending.grab_id
)
.execute(database.pool())
.await?;
self.forget_probes(&paths).await;
return Ok(Some(Outcome::new(
format!("grab {} downloaded, not imported", pending.grab_id),
"every episode in the pack was already on disk".to_owned(),
)));
}
let imported = self
.place_episodes(database, pending, &loaded.root_path, imports)
.await?;
sqlx::query!(
"UPDATE grabs
SET state = 'imported',
imported_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
pending.grab_id
)
.execute(database.pool())
.await?;
self.forget_probes(&paths).await;
self.refresh_jellyfin().await;
Ok(Some(Outcome::new(
format!("grab {} downloaded, not imported", pending.grab_id),
format!(
"imported {imported} episode file(s) of {}",
pending.series_title
),
)))
}
/// Hardlink each judged file into the §7.4 TV layout and settle its rows.
async fn place_episodes(
&self,
database: &Db,
pending: &PendingTvImport,
root_path: &str,
imports: Vec<(Assignment, Option<Rule>)>,
) -> Result<usize, ImportError> {
let claimed_source = arr_parse::parse(&pending.release_name)
.source
.map(Source::from);
let season_number = u16::try_from(pending.season_number).unwrap_or_default();
let mut imported = 0usize;
for (assignment, waiver) in imports {
let episode = &assignment.episode;
let feature = &assignment.file;
let tags = layout::attribute_tags(&feature.media, claimed_source);
let extension = feature.path.extension().and_then(|ext| ext.to_str());
let destination = Path::new(root_path)
.join(layout::series_folder(
&pending.series_title,
pending.series_year,
pending.series_tmdb_id,
))
.join(layout::season_folder(season_number))
.join(layout::episode_file_name(
&pending.series_title,
pending.series_year,
season_number,
u16::try_from(episode.number).unwrap_or_default(),
&episode.title,
&tags,
extension,
));
let source_path = feature.path.clone();
let link_target = destination.clone();
tokio::task::spawn_blocking(move || place(&source_path, &link_target)).await??;
record_episode_import(database, episode.id, feature, waiver.as_ref(), &destination)
.await?;
imported += 1;
tracing::info!(
grab_id = pending.grab_id,
episode_id = episode.id,
series = pending.series_title,
path = %destination.display(),
waived = waiver.is_some(),
"imported"
);
}
Ok(imported)
}
/// §5.7 hard fail for a TV grab: blacklist the release, fail the grab and
/// reopen only the episodes it was downloading. The season is never
/// blacklisted — grab selection falls back to per-episode.
async fn hard_fail_tv(
&self,
database: &Db,
pending: &PendingTvImport,
reason: &str,
) -> Result<Outcome, ImportError> {
arr_db::blacklist::add(
database.pool(),
Some(&pending.infohash),
&pending.release_name,
reason,
)
.await?;
sqlx::query!(
"UPDATE grabs SET state = 'failed' WHERE id = ?",
pending.grab_id
)
.execute(database.pool())
.await?;
match pending.episode_id {
Some(episode_id) => {
sqlx::query!(
"UPDATE episodes
SET state = 'missing',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
episode_id
)
.execute(database.pool())
.await?;
}
None => {
sqlx::query!(
"UPDATE episodes
SET state = 'missing',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE season_id = ? AND state = 'downloading'
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = episodes.id
)",
pending.season_id
)
.execute(database.pool())
.await?;
}
}
tracing::warn!(
grab_id = pending.grab_id,
series = pending.series_title,
release = pending.release_name,
reason,
"hard fail post-probe; release blacklisted, episodes reopened, torrent left seeding"
);
Ok(Outcome::new(
format!("grab {} hard-failed post-probe: {reason}", pending.grab_id),
format!("blacklisted {}", pending.release_name),
))
}
/// §7.5: the filesystem watcher misses the just-hardlinked file. A
/// failure to reach Jellyfin must not fail the import, which has already
/// succeeded.
@@ -462,6 +735,258 @@ async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportErro
.collect())
}
/// A TV grab Transmission finished downloading — one episode or a season
/// pack — not yet imported.
#[derive(Debug, Clone)]
struct PendingTvImport {
grab_id: i64,
infohash: String,
/// `Some` for an episode grab, `None` for a season pack.
episode_id: Option<i64>,
season_id: i64,
season_number: i64,
series_tmdb_id: i64,
series_title: String,
series_year: Option<i64>,
original_language: Option<String>,
release_name: String,
}
/// An episode a downloaded TV grab could satisfy.
#[derive(Debug, Clone)]
struct TargetEpisode {
id: i64,
number: i64,
title: String,
has_file: bool,
}
/// One probed video file tied to the episode it holds.
#[derive(Debug)]
struct Assignment {
episode: TargetEpisode,
file: arr_probe::ProbedFile,
}
/// The TV side of the gap (§8): downloaded episode and season grabs that no
/// import has settled.
async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, ImportError> {
let mut pending = Vec::new();
let episode_rows = sqlx::query!(
r#"
SELECT g.id AS "grab_id!: i64",
g.infohash AS "infohash!: String",
e.id AS "episode_id!: i64",
se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.tmdb_id AS "series_tmdb_id!: i64",
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
r.name AS "release_name!: String"
FROM grabs g
JOIN episodes e ON e.id = g.target_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN releases r ON r.id = g.release_id
WHERE g.state = 'downloaded' AND g.target_kind = 'episode'
ORDER BY g.id
"#
)
.fetch_all(database.pool())
.await?;
pending.extend(episode_rows.into_iter().map(|row| PendingTvImport {
grab_id: row.grab_id,
infohash: row.infohash,
episode_id: Some(row.episode_id),
season_id: row.season_id,
season_number: row.season_number,
series_tmdb_id: row.series_tmdb_id,
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
release_name: row.release_name,
}));
let season_rows = sqlx::query!(
r#"
SELECT g.id AS "grab_id!: i64",
g.infohash AS "infohash!: String",
se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.tmdb_id AS "series_tmdb_id!: i64",
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
r.name AS "release_name!: String"
FROM grabs g
JOIN seasons se ON se.id = g.target_id
JOIN series s ON s.id = se.series_id
JOIN releases r ON r.id = g.release_id
WHERE g.state = 'downloaded' AND g.target_kind = 'season'
ORDER BY g.id
"#
)
.fetch_all(database.pool())
.await?;
pending.extend(season_rows.into_iter().map(|row| PendingTvImport {
grab_id: row.grab_id,
infohash: row.infohash,
episode_id: None,
season_id: row.season_id,
season_number: row.season_number,
series_tmdb_id: row.series_tmdb_id,
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
release_name: row.release_name,
}));
pending.sort_by_key(|row| row.grab_id);
Ok(pending)
}
/// The episodes a grab could satisfy: one for an episode grab, the whole
/// season for a pack.
async fn target_episodes(
database: &Db,
pending: &PendingTvImport,
) -> Result<Vec<TargetEpisode>, ImportError> {
let rows = sqlx::query!(
r#"
SELECT e.id AS "id!: i64",
e.number AS "number!: i64",
e.title AS "title!: String",
EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
) AS "has_file!: bool"
FROM episodes e
WHERE e.season_id = ?
ORDER BY e.number
"#,
pending.season_id
)
.fetch_all(database.pool())
.await?;
let episodes = rows.into_iter().map(|row| TargetEpisode {
id: row.id,
number: row.number,
title: row.title,
has_file: row.has_file,
});
Ok(match pending.episode_id {
Some(episode_id) => episodes
.filter(|episode| episode.id == episode_id)
.collect(),
None => episodes.collect(),
})
}
/// Tie each readable video file to the episode its own name claims (§5.6:
/// per-file names are the only pre-probe truth a pack carries).
///
/// A file claiming several episodes lands on the first target it covers, one
/// file per episode, largest file winning a collision. For a single-episode
/// grab whose only video file carries no tag, the file is the episode.
fn assign_files(
pending: &PendingTvImport,
episodes: &[TargetEpisode],
files: Vec<arr_probe::ProbedFile>,
) -> Vec<Assignment> {
let season = u32::try_from(pending.season_number).unwrap_or_default();
let mut by_episode: HashMap<i64, arr_probe::ProbedFile> = HashMap::new();
let mut untagged: Vec<arr_probe::ProbedFile> = Vec::new();
for file in files {
let name = file.path.file_name().and_then(|name| name.to_str());
let claim = name.and_then(|name| arr_parse::parse(name).episode);
let Some(claim) = claim else {
untagged.push(file);
continue;
};
let covered = episodes.iter().find(|episode| {
claim.covers(season, u32::try_from(episode.number).unwrap_or_default())
});
let Some(episode) = covered else {
continue;
};
match by_episode.entry(episode.id) {
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(file);
}
std::collections::hash_map::Entry::Occupied(mut entry) => {
if file.size > entry.get().size {
entry.insert(file);
}
}
}
}
// A single-episode torrent often names its one file after nothing
// useful. One target, one untagged video: that is the episode.
if pending.episode_id.is_some() && by_episode.is_empty() && untagged.len() == 1 {
if let (Some(episode), Some(file)) = (episodes.first(), untagged.pop()) {
by_episode.insert(episode.id, file);
}
}
let mut assignments: Vec<Assignment> = episodes
.iter()
.filter_map(|episode| {
by_episode.remove(&episode.id).map(|file| Assignment {
episode: episode.clone(),
file,
})
})
.collect();
assignments.sort_by_key(|assignment| assignment.episode.number);
assignments
}
/// Settle a placed episode file into the rows: the `media_files` record, and
/// the episode itself. The upsert on path is the same crash seam the movie
/// import leans on.
async fn record_episode_import(
database: &Db,
episode_id: i64,
feature: &arr_probe::ProbedFile,
waiver: Option<&Rule>,
destination: &Path,
) -> Result<(), ImportError> {
let probed = probed_json(&feature.media).to_string();
let waiver_json = waiver.map(|rule| serde_json::json!({ "rule": rule.name() }).to_string());
let size = i64::try_from(feature.size).unwrap_or(i64::MAX);
let path_text = destination.to_string_lossy().into_owned();
sqlx::query!(
"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver)
VALUES ('episode', ?, ?, ?, ?, ?)
ON CONFLICT (path) DO UPDATE SET
size = excluded.size,
probed = excluded.probed,
waiver = excluded.waiver,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
episode_id,
path_text,
size,
probed,
waiver_json
)
.execute(database.pool())
.await?;
sqlx::query!(
"UPDATE episodes
SET state = 'available',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
episode_id
)
.execute(database.pool())
.await?;
Ok(())
}
/// The `probed` column (§4, §5.6): what `ffprobe` found, in the spellings the
/// policy columns use.
fn probed_json(media: &ProbedMedia) -> serde_json::Value {
@@ -1049,6 +1574,276 @@ mod tests {
);
}
/// The TV probe: a 2160p HDR10 file with an English track, sized inside
/// the 2160p band.
const TV_HDR10_PROBE: &str = r#"{
"format": {"format_name": "matroska,webm", "duration": "3300.0", "size": "10737418240"},
"streams": [
{"codec_type": "video", "codec_name": "hevc", "width": 3840, "height": 1600,
"color_transfer": "smpte2084"},
{"codec_type": "audio", "codec_name": "eac3", "tags": {"language": "eng"}}
]
}"#;
const TV_DV5_PROBE: &str = r#"{
"format": {"format_name": "matroska,webm", "duration": "3300.0", "size": "10737418240"},
"streams": [
{"codec_type": "video", "codec_name": "hevc", "width": 3840, "height": 1600,
"color_transfer": "smpte2084",
"side_data_list": [{"side_data_type": "DOVI configuration record", "dv_profile": 5}]},
{"codec_type": "audio", "codec_name": "eac3", "tags": {"language": "eng"}}
]
}"#;
const PACK_RELEASE_NAME: &str = "Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos";
struct TvHarness {
_dir: tempfile::TempDir,
database: Db,
downloads: PathBuf,
library: PathBuf,
action: ImportAction,
_server: MockServer,
}
/// A downloaded season-pack grab for Fallout S01E01-E02, its two files
/// sitting in the download root.
async fn tv_harness(media_json: &str) -> TvHarness {
let dir = tempfile::tempdir().unwrap();
let downloads = dir.path().join("downloads");
let library = dir.path().join("library");
std::fs::create_dir_all(downloads.join("Fallout.S01")).unwrap();
std::fs::create_dir_all(&library).unwrap();
std::fs::write(downloads.join("Fallout.S01/Fallout.S01E01.mkv"), b"e1").unwrap();
std::fs::write(downloads.join("Fallout.S01/Fallout.S01E02.mkv"), b"e2").unwrap();
let database = Db::connect(dir.path().join("arr.db")).await.unwrap();
database.migrate().await.unwrap();
let library_text = library.to_string_lossy().into_owned();
sqlx::query("UPDATE roots SET path = ? WHERE kind = 'tv' AND audience = 'main'")
.bind(&library_text)
.execute(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id)
SELECT 106379, 'Fallout', 2024, 'en', id
FROM roots WHERE kind = 'tv' AND audience = 'main'",
)
.execute(database.pool())
.await
.unwrap();
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (1, 1) RETURNING id",
)
.fetch_one(database.pool())
.await
.unwrap();
for number in 1..=2 {
sqlx::query(
"INSERT INTO episodes (season_id, number, title, air_date, wanted, state)
VALUES (?, ?, ?, '2024-04-11', 1, 'downloading')",
)
.bind(season_id)
.bind(number)
.bind(format!("The Episode {number}"))
.execute(database.pool())
.await
.unwrap();
}
let release_id = sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, 'pack', ?, 85899345920, 'magnet:x', '{}', 'eligible')",
)
.bind(PACK_RELEASE_NAME)
.execute(database.pool())
.await
.unwrap()
.last_insert_rowid();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, 'season', ?, ?, 'downloaded')",
)
.bind(release_id)
.bind(season_id)
.bind(INFOHASH)
.execute(database.pool())
.await
.unwrap();
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"result": "success",
"arguments": {"torrents": [{
"hashString": INFOHASH,
"downloadDir": downloads.to_string_lossy(),
"files": [
{"name": "Fallout.S01/Fallout.S01E01.mkv", "length": 2, "bytesCompleted": 2},
{"name": "Fallout.S01/Fallout.S01E02.mkv", "length": 2, "bytesCompleted": 2}
]
}]}
})))
.mount(&server)
.await;
let jellyfin_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/Library/Refresh"))
.respond_with(ResponseTemplate::new(204))
.mount(&jellyfin_server)
.await;
let prober = Prober::new().with_binary(fake_ffprobe(dir.path(), media_json));
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
let action = ImportAction::new(
TransmissionClient::new(&server.uri()).unwrap(),
prober,
jellyfin,
);
TvHarness {
_dir: dir,
database,
downloads,
library,
action,
_server: server,
}
}
fn expected_episode_file(library: &Path, number: u16) -> PathBuf {
library
.join("Fallout (2024) [tmdbid-106379]")
.join("Season 01")
.join(format!(
"Fallout (2024) - S01E{number:02} - The Episode {number} [2160p][WEB-DL][HDR10].mkv"
))
}
/// A season pack lands each episode file on the §7.4 TV layout.
#[tokio::test]
async fn a_season_pack_imports_every_episode() {
let h = tv_harness(TV_HDR10_PROBE).await;
let outcomes = h.action.tick(&h.database).await.unwrap();
assert_eq!(outcomes.len(), 1);
for number in 1..=2u16 {
let file = expected_episode_file(&h.library, number);
assert!(file.is_file(), "missing {}", file.display());
}
let states: Vec<String> = sqlx::query_scalar("SELECT state FROM episodes ORDER BY number")
.fetch_all(h.database.pool())
.await
.unwrap();
assert_eq!(states, vec!["available", "available"]);
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
assert_eq!(grab_state, "imported");
assert!(
h.downloads.join("Fallout.S01/Fallout.S01E01.mkv").is_file(),
"§7.3: the torrent keeps seeding"
);
}
/// The fourth acceptance case: a pack containing an episode already on
/// disk must not re-import what exists.
#[tokio::test]
async fn a_season_pack_never_reimports_an_episode_already_on_disk() {
let h = tv_harness(TV_HDR10_PROBE).await;
let existing = h.library.join("existing-e01.mkv");
std::fs::write(&existing, b"the copy that is already there").unwrap();
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size)
SELECT 'episode', id, ?, 30 FROM episodes WHERE number = 1",
)
.bind(existing.to_string_lossy().into_owned())
.execute(h.database.pool())
.await
.unwrap();
sqlx::query("UPDATE episodes SET state = 'available' WHERE number = 1")
.execute(h.database.pool())
.await
.unwrap();
let outcomes = h.action.tick(&h.database).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert!(
!expected_episode_file(&h.library, 1).exists(),
"episode 1 is on disk already and must not be re-imported"
);
assert!(expected_episode_file(&h.library, 2).is_file());
let episode_one_files: Vec<(String, i64)> = sqlx::query_as(
"SELECT f.path, f.size FROM media_files f
JOIN episodes e ON e.id = f.owner_id
WHERE f.owner_kind = 'episode' AND e.number = 1",
)
.fetch_all(h.database.pool())
.await
.unwrap();
assert_eq!(
episode_one_files,
vec![(existing.to_string_lossy().into_owned(), 30)],
"episode 1 keeps exactly its pre-existing file row"
);
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
assert_eq!(grab_state, "imported");
}
/// The third acceptance case, import side: a pack whose file hard-fails
/// blacklists that release and reopens the episodes — it does not
/// blacklist or block the season.
#[tokio::test]
async fn a_hard_failed_pack_reopens_the_season_per_episode() {
let h = tv_harness(TV_DV5_PROBE).await;
let outcomes = h.action.tick(&h.database).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert!(
std::fs::read_dir(&h.library).unwrap().next().is_none(),
"nothing may reach the library"
);
let blacklist: Vec<(String, String)> =
sqlx::query_as("SELECT normalised_name, reason FROM blacklist")
.fetch_all(h.database.pool())
.await
.unwrap();
assert_eq!(
blacklist,
vec![(
arr_parse::normalise(PACK_RELEASE_NAME),
"dolby_vision_profile".to_owned()
)],
"only the release is blacklisted, never the season"
);
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
assert_eq!(grab_state, "failed");
let states: Vec<(String, bool)> =
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
.fetch_all(h.database.pool())
.await
.unwrap();
assert_eq!(
states,
vec![("missing".to_owned(), true), ("missing".to_owned(), true)],
"the gap reopens per episode, still wanted"
);
assert!(
h.downloads.join("Fallout.S01/Fallout.S01E01.mkv").is_file(),
"§7.3: the torrent is untouched"
);
}
/// The `EXDEV` fallback path lands whole files via rename (§7.2).
#[test]
fn the_copy_fallback_lands_a_whole_file_and_cleans_up() {