feat: season-pack vs per-episode grab selection (#94)
This commit was merged in pull request #94.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
//! Season-pack versus per-episode grab selection (`DESIGN.md` §13 phase 6).
|
||||
//!
|
||||
//! The operator's rule: season packs only when the season is fully released;
|
||||
//! while a season is airing, grab per episode. A completed season with no
|
||||
//! episodes on disk prefers the pack — one torrent, better seeded, consistent
|
||||
//! encode. A pack that hard-failed must not cost the whole season, so the
|
||||
//! season falls back to per-episode instead of being blacklisted outright.
|
||||
//!
|
||||
//! Re-grabbing a pack once an airing season completes is deliberately not
|
||||
//! done (§14): a season with any episode already on disk grabs per episode.
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// How a season's missing wanted episodes should be grabbed next.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SeasonGrabMode {
|
||||
/// One season-pack torrent for the whole season.
|
||||
SeasonPack,
|
||||
/// One grab per aired wanted episode.
|
||||
PerEpisode,
|
||||
}
|
||||
|
||||
/// Everything the season-pack decision depends on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SeasonGrabFacts<'a> {
|
||||
/// One entry per episode the season is known to hold, aired or not.
|
||||
/// `None` is an announced episode with no date yet.
|
||||
pub air_dates: &'a [Option<SystemTime>],
|
||||
pub now: SystemTime,
|
||||
/// Whether any episode of the season already has a file (§14: nothing
|
||||
/// re-grabs a pack over episodes on disk, and a pack must not re-import
|
||||
/// what exists).
|
||||
pub any_episode_on_disk: bool,
|
||||
/// Whether a season-pack grab for this season already hard-failed.
|
||||
pub pack_hard_failed: bool,
|
||||
}
|
||||
|
||||
/// Picks the grab mode for one season.
|
||||
///
|
||||
/// A season is fully released only when every known episode has an air date
|
||||
/// in the past. An episode with no date could still be unaired, and grabbing
|
||||
/// a "complete" pack of a season that is not complete costs a whole torrent
|
||||
/// of the wrong thing — so an undated episode keeps the season per-episode.
|
||||
#[must_use]
|
||||
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
|
||||
let fully_released = !facts.air_dates.is_empty()
|
||||
&& facts
|
||||
.air_dates
|
||||
.iter()
|
||||
.all(|date| date.is_some_and(|date| date <= facts.now));
|
||||
|
||||
if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed {
|
||||
SeasonGrabMode::SeasonPack
|
||||
} else {
|
||||
SeasonGrabMode::PerEpisode
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
const DAY: Duration = Duration::from_hours(24);
|
||||
|
||||
fn facts(air_dates: &[Option<SystemTime>]) -> SeasonGrabFacts<'_> {
|
||||
SeasonGrabFacts {
|
||||
air_dates,
|
||||
now: SystemTime::UNIX_EPOCH + 100 * DAY,
|
||||
any_episode_on_disk: false,
|
||||
pack_hard_failed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_released_season_with_nothing_on_disk_takes_the_pack() {
|
||||
let aired = [
|
||||
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
|
||||
Some(SystemTime::UNIX_EPOCH + 17 * DAY),
|
||||
];
|
||||
assert_eq!(season_grab_mode(&facts(&aired)), SeasonGrabMode::SeasonPack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_airing_season_grabs_per_episode() {
|
||||
let airing = [
|
||||
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
|
||||
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
|
||||
];
|
||||
assert_eq!(
|
||||
season_grab_mode(&facts(&airing)),
|
||||
SeasonGrabMode::PerEpisode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undated_episode_keeps_the_season_per_episode() {
|
||||
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
|
||||
assert_eq!(
|
||||
season_grab_mode(&facts(&undated)),
|
||||
SeasonGrabMode::PerEpisode
|
||||
);
|
||||
assert_eq!(season_grab_mode(&facts(&[])), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_failed_pack_falls_back_to_per_episode() {
|
||||
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||
let mut facts = facts(&aired);
|
||||
facts.pack_hard_failed = true;
|
||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
|
||||
/// §14: episodes already on disk are never re-grabbed as part of a pack.
|
||||
#[test]
|
||||
fn a_season_with_an_episode_on_disk_grabs_per_episode() {
|
||||
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||
let mut facts = facts(&aired);
|
||||
facts.any_episode_on_disk = true;
|
||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,53 @@ pub fn movie_file_name(
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-series folder: `Bluey (2018) [tmdbid-82728]`. Same shape as a
|
||||
/// movie's, and the same reasons for it.
|
||||
#[must_use]
|
||||
pub fn series_folder(title: &str, year: Option<i64>, tmdb_id: i64) -> String {
|
||||
movie_folder(title, year, tmdb_id)
|
||||
}
|
||||
|
||||
/// The per-season folder inside a series folder: `Season 01`.
|
||||
#[must_use]
|
||||
pub fn season_folder(season: u16) -> String {
|
||||
format!("Season {season:02}")
|
||||
}
|
||||
|
||||
/// An episode's filename: `Bluey (2018) - S01E02 - Hospital [1080p][WEB-DL][pt-PT].mkv`.
|
||||
///
|
||||
/// Unlike the folder there is no provider ID — Jellyfin matches episodes by
|
||||
/// the `SxxEyy` tag once the folder pinned the series.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn episode_file_name(
|
||||
series_title: &str,
|
||||
year: Option<i64>,
|
||||
season: u16,
|
||||
episode: u16,
|
||||
episode_title: &str,
|
||||
tags: &[String],
|
||||
extension: Option<&str>,
|
||||
) -> String {
|
||||
let series = sanitise(series_title);
|
||||
let series = match year {
|
||||
Some(year) => format!("{series} ({year})"),
|
||||
None => series,
|
||||
};
|
||||
let episode_title = sanitise(episode_title);
|
||||
let tags = tags.iter().fold(String::new(), |mut out, tag| {
|
||||
out.push('[');
|
||||
out.push_str(tag);
|
||||
out.push(']');
|
||||
out
|
||||
});
|
||||
let stem = format!("{series} - S{season:02}E{episode:02} - {episode_title} {tags}");
|
||||
match extension {
|
||||
Some(extension) => format!("{stem}.{extension}"),
|
||||
None => stem,
|
||||
}
|
||||
}
|
||||
|
||||
/// The §7.4 attribute tags, in a fixed order: resolution, source, HDR,
|
||||
/// Portuguese audio.
|
||||
///
|
||||
@@ -147,6 +194,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The exact §7.4 TV example.
|
||||
#[test]
|
||||
fn the_design_document_episode_example() {
|
||||
let media = probed(
|
||||
Resolution::R1080p,
|
||||
HdrFormat::Sdr,
|
||||
vec![Language::PortuguesePortugal],
|
||||
);
|
||||
let tags = attribute_tags(&media, Some(Source::WebDl));
|
||||
|
||||
assert_eq!(
|
||||
series_folder("Bluey", Some(2018), 82_728),
|
||||
"Bluey (2018) [tmdbid-82728]"
|
||||
);
|
||||
assert_eq!(season_folder(1), "Season 01");
|
||||
assert_eq!(
|
||||
episode_file_name("Bluey", Some(2018), 1, 2, "Hospital", &tags, Some("mkv")),
|
||||
"Bluey (2018) - S01E02 - Hospital [1080p][WEB-DL][pt-PT].mkv"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kids audit surface: a pt-PT track is tagged, SDR is not.
|
||||
#[test]
|
||||
fn portuguese_audio_is_tagged_and_sdr_is_not() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::{collections::BTreeMap, fmt, path::PathBuf, time::SystemTime};
|
||||
|
||||
pub mod grabbing;
|
||||
pub mod lang;
|
||||
pub mod layout;
|
||||
pub mod matching;
|
||||
@@ -13,6 +14,7 @@ pub mod status;
|
||||
pub mod tracking;
|
||||
|
||||
pub use arr_parse::NameClaims as ParsedRelease;
|
||||
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
pub use matching::{match_movie, MatchKind, MovieMatch, WantedMovie};
|
||||
pub use score::{Score, ScoreWeights};
|
||||
pub use status::{derive_series_status, SeriesStatus};
|
||||
|
||||
+249
-96
@@ -118,7 +118,7 @@ impl GrabAction {
|
||||
}
|
||||
|
||||
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, GrabError> {
|
||||
let mut outcomes = self.track_sent_grabs(database).await?;
|
||||
let mut outcomes = self.grabber.track_sent_grabs(database).await?;
|
||||
let gaps = pending_movies(database).await?;
|
||||
if gaps.is_empty() {
|
||||
return Ok(outcomes);
|
||||
@@ -247,61 +247,6 @@ impl GrabAction {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Move grabs Transmission reports as complete out of `sent`.
|
||||
///
|
||||
/// Transmission is authoritative and its view is rebuilt on every tick
|
||||
/// rather than cached (§8), so this is also what reconstructs in-flight
|
||||
/// state after a restart.
|
||||
async fn track_sent_grabs(&self, database: &Db) -> Result<Vec<Outcome>, GrabError> {
|
||||
let sent = sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", infohash AS "infohash!: String", target_id AS "target_id!: i64"
|
||||
FROM grabs WHERE state = 'sent'"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
if sent.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let torrents: HashMap<String, f64> = self
|
||||
.grabber
|
||||
.transmission
|
||||
.list_torrents()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|torrent| (torrent.hash.to_ascii_lowercase(), torrent.progress))
|
||||
.collect();
|
||||
|
||||
let mut outcomes = Vec::new();
|
||||
for grab in sent {
|
||||
let Some(progress) = torrents.get(&grab.infohash.to_ascii_lowercase()) else {
|
||||
// Gone from Transmission. Deciding whether that is a failure
|
||||
// or a manual removal is issue #86's; leaving the row alone
|
||||
// keeps this tick from re-grabbing behind the operator.
|
||||
continue;
|
||||
};
|
||||
if *progress < 1.0 {
|
||||
continue;
|
||||
}
|
||||
sqlx::query!(
|
||||
"UPDATE grabs SET state = 'downloaded' WHERE id = ?",
|
||||
grab.id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
outcomes.push(Outcome::new(
|
||||
format!("grab {} downloaded, still marked sent", grab.id),
|
||||
format!("marked grab {} downloaded", grab.id),
|
||||
));
|
||||
tracing::info!(
|
||||
grab_id = grab.id,
|
||||
movie_id = grab.target_id,
|
||||
"download complete"
|
||||
);
|
||||
}
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// Search every indexer for one title, cache each candidate with its
|
||||
/// verdict and score (§9.3), and return the eligible ones best first.
|
||||
///
|
||||
@@ -409,7 +354,7 @@ impl GrabAction {
|
||||
.send_winner(
|
||||
database,
|
||||
&GrabTarget {
|
||||
movie_id: movie.id,
|
||||
scope: GrabScope::Movie { movie_id: movie.id },
|
||||
title: &movie.title,
|
||||
counts_as_attempt: true,
|
||||
},
|
||||
@@ -436,13 +381,57 @@ pub(crate) struct Grabber {
|
||||
/// The title a winning release is being grabbed for.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct GrabTarget<'a> {
|
||||
pub(crate) movie_id: i64,
|
||||
pub(crate) scope: GrabScope,
|
||||
pub(crate) title: &'a str,
|
||||
/// Whether a grab that does not complete counts toward the targeted
|
||||
/// search backoff (§6.2). RSS never backs off, so it passes `false`.
|
||||
pub(crate) counts_as_attempt: bool,
|
||||
}
|
||||
|
||||
/// What a grab targets: the `grabs` row's kind and id, plus the episodes the
|
||||
/// torrent covers — attempts and state changes land on those leaves (§4.1).
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum GrabScope {
|
||||
Movie {
|
||||
movie_id: i64,
|
||||
},
|
||||
Episode {
|
||||
episode_id: i64,
|
||||
},
|
||||
/// A season pack: one torrent, one `grabs` row on the season, every
|
||||
/// missing wanted episode it covers flipped to downloading.
|
||||
Season {
|
||||
season_id: i64,
|
||||
episode_ids: Vec<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl GrabScope {
|
||||
fn target_kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Movie { .. } => "movie",
|
||||
Self::Episode { .. } => "episode",
|
||||
Self::Season { .. } => "season",
|
||||
}
|
||||
}
|
||||
|
||||
fn target_id(&self) -> i64 {
|
||||
match self {
|
||||
Self::Movie { movie_id } => *movie_id,
|
||||
Self::Episode { episode_id } => *episode_id,
|
||||
Self::Season { season_id, .. } => *season_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn episode_ids(&self) -> &[i64] {
|
||||
match self {
|
||||
Self::Movie { .. } => &[],
|
||||
Self::Episode { episode_id } => std::slice::from_ref(episode_id),
|
||||
Self::Season { episode_ids, .. } => episode_ids,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Grabber {
|
||||
pub(crate) fn new(
|
||||
transmission: TransmissionClient,
|
||||
@@ -456,15 +445,76 @@ impl Grabber {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move grabs Transmission reports as complete out of `sent`, whatever
|
||||
/// they target.
|
||||
///
|
||||
/// Transmission is authoritative and its view is rebuilt on every tick
|
||||
/// rather than cached (§8), so this is also what reconstructs in-flight
|
||||
/// state after a restart. Both grab actions call it; whichever runs first
|
||||
/// does the work and the other finds nothing.
|
||||
pub(crate) async fn track_sent_grabs(&self, database: &Db) -> Result<Vec<Outcome>, GrabError> {
|
||||
let sent = sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", infohash AS "infohash!: String",
|
||||
target_kind AS "target_kind!: String", target_id AS "target_id!: i64"
|
||||
FROM grabs WHERE state = 'sent'"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
if sent.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let torrents: HashMap<String, f64> = self
|
||||
.transmission
|
||||
.list_torrents()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|torrent| (torrent.hash.to_ascii_lowercase(), torrent.progress))
|
||||
.collect();
|
||||
|
||||
let mut outcomes = Vec::new();
|
||||
for grab in sent {
|
||||
let Some(progress) = torrents.get(&grab.infohash.to_ascii_lowercase()) else {
|
||||
// Gone from Transmission. Deciding whether that is a failure
|
||||
// or a manual removal is issue #86's; leaving the row alone
|
||||
// keeps this tick from re-grabbing behind the operator.
|
||||
continue;
|
||||
};
|
||||
if *progress < 1.0 {
|
||||
continue;
|
||||
}
|
||||
sqlx::query!(
|
||||
"UPDATE grabs SET state = 'downloaded' WHERE id = ?",
|
||||
grab.id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
outcomes.push(Outcome::new(
|
||||
format!("grab {} downloaded, still marked sent", grab.id),
|
||||
format!("marked grab {} downloaded", grab.id),
|
||||
));
|
||||
tracing::info!(
|
||||
grab_id = grab.id,
|
||||
target_kind = grab.target_kind,
|
||||
target_id = grab.target_id,
|
||||
"download complete"
|
||||
);
|
||||
}
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
async fn record_attempt(
|
||||
&self,
|
||||
database: &Db,
|
||||
target: &GrabTarget<'_>,
|
||||
) -> Result<(), GrabError> {
|
||||
if target.counts_as_attempt {
|
||||
record_search(database, target.movie_id).await?;
|
||||
if !target.counts_as_attempt {
|
||||
return Ok(());
|
||||
}
|
||||
match &target.scope {
|
||||
GrabScope::Movie { movie_id } => record_search(database, *movie_id).await,
|
||||
scope => record_episode_search(database, scope.episode_ids()).await,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add the winning release to Transmission and record the grab.
|
||||
@@ -513,29 +563,49 @@ impl Grabber {
|
||||
|
||||
// A duplicate here is the restart case: the torrent was added before
|
||||
// the process died. `DO NOTHING` keeps the original row.
|
||||
let target_kind = target.scope.target_kind();
|
||||
let target_id = target.scope.target_id();
|
||||
let inserted = sqlx::query!(
|
||||
r#"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, 'movie', ?, ?, 'sent')
|
||||
VALUES (?, ?, ?, ?, 'sent')
|
||||
ON CONFLICT (infohash) DO NOTHING
|
||||
RETURNING id AS "id!: i64""#,
|
||||
winner.id,
|
||||
target.movie_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
infohash
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE movies SET state = 'downloading',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
target.movie_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
match &target.scope {
|
||||
GrabScope::Movie { movie_id } => {
|
||||
sqlx::query!(
|
||||
"UPDATE movies SET state = 'downloading',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
movie_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
scope => {
|
||||
for episode_id in scope.episode_ids() {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes SET state = 'downloading',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
episode_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(inserted) = inserted else {
|
||||
tracing::info!(
|
||||
movie_id = target.movie_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
infohash,
|
||||
"grab already recorded for this torrent"
|
||||
);
|
||||
@@ -543,7 +613,8 @@ impl Grabber {
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
movie_id = target.movie_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
title = target.title,
|
||||
release = winner.name,
|
||||
score = winner.score,
|
||||
@@ -552,7 +623,7 @@ impl Grabber {
|
||||
"grabbed"
|
||||
);
|
||||
Ok(Some(Outcome::new(
|
||||
format!("movie {} wanted with no file", target.movie_id),
|
||||
format!("{target_kind} {target_id} wanted with no file"),
|
||||
format!("grabbed {} as grab {}", winner.name, inserted.id),
|
||||
)))
|
||||
}
|
||||
@@ -592,7 +663,7 @@ impl Grabber {
|
||||
// The earlier grab's torrent, still working off its seeding
|
||||
// obligation (§7.3). Nothing here deletes a torrent.
|
||||
tracing::warn!(
|
||||
movie_id = target.movie_id,
|
||||
title = target.title,
|
||||
release = release_name,
|
||||
infohash = added.hash,
|
||||
"blacklisted torrent re-listed under a new name; left seeding"
|
||||
@@ -602,7 +673,7 @@ impl Grabber {
|
||||
// obligation and has nothing on disk worth keeping.
|
||||
self.transmission.remove_torrent(added.id, true).await?;
|
||||
tracing::warn!(
|
||||
movie_id = target.movie_id,
|
||||
title = target.title,
|
||||
release = release_name,
|
||||
infohash = added.hash,
|
||||
"blacklisted torrent re-listed under a new name; removed"
|
||||
@@ -645,7 +716,7 @@ pub(crate) struct Eligible {
|
||||
pub(crate) indexer_id: i64,
|
||||
pub(crate) guid: String,
|
||||
pub(crate) name: String,
|
||||
download_url: String,
|
||||
pub(crate) download_url: String,
|
||||
pub(crate) score: i64,
|
||||
}
|
||||
|
||||
@@ -703,13 +774,18 @@ async fn pending_movies(database: &Db) -> Result<Vec<PendingMovie>, GrabError> {
|
||||
}
|
||||
|
||||
fn search_due(movie: &PendingMovie) -> bool {
|
||||
let Some(last_searched_at) = &movie.last_searched_at else {
|
||||
backoff_elapsed(movie.search_attempts, movie.last_searched_at.as_deref())
|
||||
}
|
||||
|
||||
/// The §6.2 targeted-search backoff, shared by movie and episode search.
|
||||
pub(crate) fn backoff_elapsed(search_attempts: i64, last_searched_at: Option<&str>) -> bool {
|
||||
let Some(last_searched_at) = last_searched_at else {
|
||||
return true;
|
||||
};
|
||||
let Ok(last_searched_at) = chrono::DateTime::parse_from_rfc3339(last_searched_at) else {
|
||||
return true;
|
||||
};
|
||||
let backoff = match movie.search_attempts {
|
||||
let backoff = match search_attempts {
|
||||
1 => chrono::TimeDelta::hours(1),
|
||||
2 => chrono::TimeDelta::hours(6),
|
||||
3 => chrono::TimeDelta::days(1),
|
||||
@@ -756,6 +832,67 @@ pub(crate) async fn store_release(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
policy,
|
||||
overrides,
|
||||
original_language,
|
||||
blacklist,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)
|
||||
ON CONFLICT DO NOTHING",
|
||||
movie_id,
|
||||
release_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(eligible)
|
||||
}
|
||||
|
||||
/// The TV counterpart: one release row, associated with every episode the
|
||||
/// claim covers — a season pack matches the whole season.
|
||||
pub(crate) async fn store_episode_release(
|
||||
database: &Db,
|
||||
episode_ids: &[i64],
|
||||
release: &SearchRelease,
|
||||
policy: &Policy,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
policy,
|
||||
overrides,
|
||||
original_language,
|
||||
blacklist,
|
||||
)
|
||||
.await?;
|
||||
for episode_id in episode_ids {
|
||||
sqlx::query!(
|
||||
"INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)
|
||||
ON CONFLICT DO NOTHING",
|
||||
episode_id,
|
||||
release_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(eligible)
|
||||
}
|
||||
|
||||
async fn classify_and_store(
|
||||
database: &Db,
|
||||
release: &SearchRelease,
|
||||
policy: &Policy,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let evaluation = evaluate(
|
||||
policy,
|
||||
@@ -824,26 +961,20 @@ pub(crate) async fn store_release(
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)
|
||||
ON CONFLICT DO NOTHING",
|
||||
movie_id,
|
||||
id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
if verdict != "eligible" {
|
||||
return Ok(None);
|
||||
return Ok((id, None));
|
||||
}
|
||||
Ok(Some(Eligible {
|
||||
Ok((
|
||||
id,
|
||||
indexer_id: release.indexer_id,
|
||||
guid: release.guid.clone(),
|
||||
name: release.name.clone(),
|
||||
download_url: release.download_url.clone(),
|
||||
score,
|
||||
}))
|
||||
Some(Eligible {
|
||||
id,
|
||||
indexer_id: release.indexer_id,
|
||||
guid: release.guid.clone(),
|
||||
name: release.name.clone(),
|
||||
download_url: release.download_url.clone(),
|
||||
score,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Record that the title was searched, so the next tick takes a different one.
|
||||
@@ -861,6 +992,28 @@ async fn record_search(database: &Db, movie_id: i64) -> Result<(), GrabError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The episode-level backoff counter (§6.2), one bump per searched episode. A
|
||||
/// season-pack search touches every episode it was trying to satisfy, so the
|
||||
/// whole season backs off together.
|
||||
pub(crate) async fn record_episode_search(
|
||||
database: &Db,
|
||||
episode_ids: &[i64],
|
||||
) -> Result<(), GrabError> {
|
||||
for episode_id in episode_ids {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes
|
||||
SET search_attempts = search_attempts + 1,
|
||||
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
episode_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `movies-main`, `tv-kids` (§7.1). Distinct from Radarr's own labels, so
|
||||
/// both stacks can run against one Transmission.
|
||||
fn label(loaded: &MoviePolicy) -> String {
|
||||
|
||||
+821
-26
@@ -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() {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use arr_indexer::ProwlarrClient;
|
||||
use arr_indexer::{Indexer, ProwlarrClient};
|
||||
|
||||
/// How long a discovered indexer list is reused.
|
||||
const CACHE_TTL: Duration = Duration::from_mins(15);
|
||||
@@ -30,10 +30,21 @@ pub enum DiscoveryError {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Cache {
|
||||
ids: Vec<i64>,
|
||||
indexers: Vec<Indexer>,
|
||||
refreshed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// Text-searchable ids, the shape the movie lanes consume.
|
||||
fn ids(&self) -> Vec<i64> {
|
||||
self.indexers
|
||||
.iter()
|
||||
.filter(|indexer| indexer.capabilities.search.available)
|
||||
.map(|indexer| indexer.id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The indexers that accept a text search, cached across ticks.
|
||||
#[derive(Debug)]
|
||||
pub struct IndexerDirectory {
|
||||
@@ -59,13 +70,23 @@ impl IndexerDirectory {
|
||||
/// A refresh that fails or times out keeps the previous list rather than
|
||||
/// stopping the tick, and only an empty cache turns that into an error.
|
||||
pub async fn searchable(&self) -> Result<Vec<i64>, DiscoveryError> {
|
||||
Ok(self.refreshed().await?.ids())
|
||||
}
|
||||
|
||||
/// Every indexer with its capabilities, for callers that build their own
|
||||
/// per-indexer request — TV search widens or narrows per `t=caps` (§6.1).
|
||||
pub async fn indexers(&self) -> Result<Vec<Indexer>, DiscoveryError> {
|
||||
Ok(self.refreshed().await?.indexers.clone())
|
||||
}
|
||||
|
||||
async fn refreshed(&self) -> Result<tokio::sync::RwLockReadGuard<'_, Cache>, DiscoveryError> {
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if cache
|
||||
.refreshed_at
|
||||
.is_some_and(|at| at.elapsed() < CACHE_TTL)
|
||||
{
|
||||
return Ok(cache.ids.clone());
|
||||
return Ok(cache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,15 +95,11 @@ impl IndexerDirectory {
|
||||
let mut cache = self.cache.write().await;
|
||||
match discovered {
|
||||
Ok(Ok(indexers)) => {
|
||||
cache.ids = indexers
|
||||
.iter()
|
||||
.filter(|indexer| indexer.capabilities.search.available)
|
||||
.map(|indexer| indexer.id)
|
||||
.collect();
|
||||
cache.indexers = indexers;
|
||||
cache.refreshed_at = Some(Instant::now());
|
||||
}
|
||||
Ok(Err(error)) if cache.ids.is_empty() => return Err(error.into()),
|
||||
Err(_) if cache.ids.is_empty() => {
|
||||
Ok(Err(error)) if cache.indexers.is_empty() => return Err(error.into()),
|
||||
Err(_) if cache.indexers.is_empty() => {
|
||||
return Err(DiscoveryError::Timeout(self.discovery_timeout))
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
@@ -93,6 +110,7 @@ impl IndexerDirectory {
|
||||
"indexer discovery timed out; using the last known list"
|
||||
),
|
||||
}
|
||||
Ok(cache.ids.clone())
|
||||
drop(cache);
|
||||
Ok(self.cache.read().await)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod jellyfin;
|
||||
mod reaper;
|
||||
pub mod reconcile;
|
||||
mod rss;
|
||||
mod tv_grab;
|
||||
mod web;
|
||||
|
||||
use std::process::ExitCode;
|
||||
@@ -24,6 +25,7 @@ use reaper::ReaperAction;
|
||||
use reconcile::{ReconcileLoop, Tick};
|
||||
use rss::RssAction;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tv_grab::TvGrabAction;
|
||||
|
||||
/// Dump the `OpenAPI` document and exit, instead of serving. `just gen-client`
|
||||
/// uses this so the TypeScript client can be regenerated without a port or a
|
||||
@@ -215,6 +217,20 @@ fn reconcile_loop(
|
||||
} else {
|
||||
tracing::warn!("Prowlarr or TMDB is not configured: nothing will be grabbed");
|
||||
}
|
||||
// TV grabbing needs no TMDB at grab time: air dates are already on the
|
||||
// episode rows, which is the same gate the digital release date is for
|
||||
// movies (§6.2).
|
||||
if let Some(prowlarr) = prowlarr.as_ref() {
|
||||
reconcile = reconcile.register(
|
||||
Tick::Reconcile,
|
||||
TvGrabAction::new(
|
||||
prowlarr.clone(),
|
||||
transmission.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
// RSS needs no TMDB: it matches what the feeds already carry against the
|
||||
// wanted list (§6.2).
|
||||
if let Some(prowlarr) = prowlarr {
|
||||
|
||||
@@ -20,7 +20,9 @@ use arr_db::{Blacklist, Db, MoviePolicy};
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
|
||||
use crate::grab::{store_release, Eligible, GrabError, GrabTarget, Grabber, SeedingRules};
|
||||
use crate::grab::{
|
||||
store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, SeedingRules,
|
||||
};
|
||||
use crate::indexers::IndexerDirectory;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
|
||||
@@ -71,7 +73,7 @@ impl RssAction {
|
||||
.send_winner(
|
||||
database,
|
||||
&GrabTarget {
|
||||
movie_id: movie.id,
|
||||
scope: GrabScope::Movie { movie_id: movie.id },
|
||||
title: &movie.title,
|
||||
// §6.2: RSS never backs off, so a failed grab here
|
||||
// must not spend one of targeted search's attempts.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
-- A grab may target a whole season: one season-pack torrent satisfies every
|
||||
-- episode in it (DESIGN.md §13 phase 6). SQLite cannot alter a CHECK, so the
|
||||
-- table is rebuilt.
|
||||
CREATE TABLE grabs_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL REFERENCES releases (id),
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('movie', 'episode', 'season')),
|
||||
target_id INTEGER NOT NULL,
|
||||
infohash TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL DEFAULT 'sent'
|
||||
CHECK (state IN ('sent', 'downloaded', 'imported', 'failed')),
|
||||
grabbed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
imported_at TEXT
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO grabs_new (id, release_id, target_kind, target_id, infohash, state, grabbed_at, imported_at)
|
||||
SELECT id, release_id, target_kind, target_id, infohash, state, grabbed_at, imported_at FROM grabs;
|
||||
|
||||
DROP TABLE grabs;
|
||||
ALTER TABLE grabs_new RENAME TO grabs;
|
||||
|
||||
CREATE INDEX grabs_state ON grabs (state);
|
||||
CREATE INDEX grabs_target ON grabs (target_kind, target_id);
|
||||
Reference in New Issue
Block a user