Merge milestone 'Season packs'
Size bands describe one episode, so a pack's size is divided by the episodes it covers before the floor and target are applied — a 2160p pack no longer loses to a 1080p one on a penalty that scaled with season length. A failed pack grab now quiets the pack lane on the shared §6.2 backoff curve instead of disabling it forever, and a manual season search waives the window. The season deck names which state it is actually in rather than blaming backoff for a sweep that was never going to run. Closes #179, #180, #181, #182
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = ?\n GROUP BY se.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episodes!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d"
|
||||
}
|
||||
+19
-7
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
|
||||
"query": "SELECT s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -16,7 +27,7 @@
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 1,
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -27,7 +38,7 @@
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -38,7 +49,7 @@
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -49,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -63,6 +74,7 @@
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -70,5 +82,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d"
|
||||
"hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failures!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
},
|
||||
{
|
||||
"name": "last_failed_at?: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\"\n FROM episodes e\n WHERE e.season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "episodes",
|
||||
"name": "air_date"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "has_file!: bool",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT last_pack_search_at FROM seasons WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "last_pack_search_at",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "last_pack_search_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26"
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS (\n SELECT 1 FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'\n ) AS \"failed!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failed!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1"
|
||||
}
|
||||
+14
-2
@@ -6,12 +6,24 @@
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "metadata_refreshed_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se\n LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = (SELECT s2.series_id FROM episodes e2\n JOIN seasons s2 ON s2.id = e2.season_id\n WHERE e2.id = ?)\n GROUP BY se.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episodes!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51"
|
||||
}
|
||||
+91
-13
@@ -6,67 +6,145 @@
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tvdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "year"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "original_language"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "root_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "auto_track"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "upstream_ended"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "metadata_refreshed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "poster_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "backdrop_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "backdrop_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
"type_info": "Float",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "vote_average"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failures!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
},
|
||||
{
|
||||
"name": "last_failed_at?: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE seasons\n SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1"
|
||||
}
|
||||
@@ -77,7 +77,8 @@ Policy
|
||||
required_audio rule set, see §5.2
|
||||
dub_blacklist [pt-BR]
|
||||
hdr_rules see §5.3
|
||||
size_bands per resolution: floor, target, penalty curve
|
||||
size_bands per resolution: floor, target, penalty curve,
|
||||
all per episode (§5.5)
|
||||
resolution_pref [2160p, 1080p]
|
||||
source_weights small tiebreaker
|
||||
|
||||
@@ -284,6 +285,28 @@ remux scores badly but stays eligible, so it is picked when it is the only
|
||||
option. The floor matters — unbounded "smaller is better" selects a 3 GB 4K
|
||||
encode that looks like mud.
|
||||
|
||||
**A band describes one episode.** For a movie the question never arises — one
|
||||
release is one film. For TV a release may carry a single episode, several, or a
|
||||
whole season, and the shipped TV numbers are episode numbers: a 1080p target of
|
||||
2 GiB is what one episode should weigh, not a ten-episode pack. So a release is
|
||||
measured by its size divided by the number of episodes it covers, and that
|
||||
per-episode figure is what both the target penalty and the floor are compared
|
||||
against. A 20 GiB pack of ten episodes is scored as 2 GiB and sits at target,
|
||||
not ten times above it.
|
||||
|
||||
The floor takes the same figure. It is a hard reject rather than a penalty, so
|
||||
comparing a pack's total against an episode-sized floor lets every pack through
|
||||
untested — wrong in the opposite direction from the target penalty. The floor
|
||||
exists to keep a 3 GB 4K encode out, and a pack of ten such encodes has to fail
|
||||
it just as plainly.
|
||||
|
||||
**An unknown episode count is one episode.** A release matched before its
|
||||
season's episodes have been revealed by a metadata refresh has nothing to divide
|
||||
by, and the rule does not guess a number: the divisor is 1 and the release is
|
||||
measured by its full size. That is the behaviour today, and it makes a pack look
|
||||
oversized rather than undersized — it fails toward rejecting a good pack rather
|
||||
than grabbing a bad one, and the next search after a refresh has the real count.
|
||||
|
||||
Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small
|
||||
tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
|
||||
does not matter. Telesync, CAM and screener are **hard filters**, not low
|
||||
|
||||
@@ -100,6 +100,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::grab_episode))
|
||||
.routes(routes!(series::search_season))
|
||||
.routes(routes!(series::season_releases))
|
||||
.routes(routes!(series::season_pack_state))
|
||||
.routes(routes!(series::grab_season_release))
|
||||
.routes(routes!(series::files))
|
||||
.routes(routes!(metadata::series_metadata))
|
||||
@@ -334,6 +335,10 @@ mod tests {
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases",
|
||||
"get",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/pack-state",
|
||||
"get",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
|
||||
"post",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_core::policy::Candidate;
|
||||
use arr_core::score::score;
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::{ParsedRelease, Policy};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
@@ -602,7 +604,7 @@ pub async fn releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
rescore(&mut releases, &policy, None)?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
@@ -611,18 +613,30 @@ pub async fn releases(
|
||||
/// daemon's winner pick; the deck the user sees must reflect policy tuning
|
||||
/// made since the release was last searched, not whatever was true then.
|
||||
///
|
||||
/// `season_lengths` maps season number to episode count for the deck's series
|
||||
/// — a size band describes one episode, so a pack's size is divided by how
|
||||
/// many it covers. `None` is the movie decks: one release is one film,
|
||||
/// whatever episode-shaped noise its name parses to.
|
||||
///
|
||||
/// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of
|
||||
/// policy weights in the thousands), so the `i64` -> `f64` cast into the
|
||||
/// column's storage type is exact.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
pub(crate) fn rescore(releases: &mut [Release], policy: &Policy) -> Result<(), ApiError> {
|
||||
pub(crate) fn rescore(
|
||||
releases: &mut [Release],
|
||||
policy: &Policy,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
) -> Result<(), ApiError> {
|
||||
let mut totals = Vec::with_capacity(releases.len());
|
||||
for release in releases.iter() {
|
||||
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?;
|
||||
let seeders = u32::try_from(release.seeders.unwrap_or(0)).unwrap_or(u32::MAX);
|
||||
let size = u64::try_from(release.size).unwrap_or(0);
|
||||
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders).total);
|
||||
let episodes = season_lengths.map_or(1, |lengths| {
|
||||
claimed_episode_count(parsed.episode.as_ref(), lengths)
|
||||
});
|
||||
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total);
|
||||
}
|
||||
let mut indices: Vec<usize> = (0..releases.len()).collect();
|
||||
indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i]));
|
||||
@@ -1430,6 +1444,7 @@ mod tests {
|
||||
arr_core::policy::Candidate::PreGrab(&parsed),
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
)
|
||||
.total;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::score;
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
|
||||
use arr_db::policy::language;
|
||||
use arr_db::{blacklist, Blacklist};
|
||||
@@ -488,6 +489,7 @@ async fn movie_releases(
|
||||
&overrides,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
None,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -511,7 +513,7 @@ async fn episode_releases(
|
||||
) -> Result<Vec<ClassifiedRelease>, ApiError> {
|
||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||
let episode = sqlx::query!(
|
||||
r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
|
||||
r#"SELECT s.id AS "series_id!: i64", s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
@@ -522,6 +524,7 @@ async fn episode_releases(
|
||||
.fetch_optional(database.pool())
|
||||
.await?
|
||||
.ok_or(ApiError::EpisodeNotFound)?;
|
||||
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
|
||||
let loaded = database
|
||||
.episode_policy(episode_id)
|
||||
.await
|
||||
@@ -564,6 +567,7 @@ async fn episode_releases(
|
||||
&loaded.overrides,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
Some(&season_lengths),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -704,14 +708,22 @@ fn classify(
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
) -> Result<ClassifiedRelease, ApiError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
|
||||
// divided by how many episodes it covers. `None` is the movie search —
|
||||
// one release is one film.
|
||||
let episodes = season_lengths.map_or(1, |lengths| {
|
||||
claimed_episode_count(parsed.episode.as_ref(), lengths)
|
||||
});
|
||||
let evaluation = evaluate(
|
||||
policy,
|
||||
overrides,
|
||||
original_language,
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
episodes,
|
||||
);
|
||||
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
||||
("rejected", Some(blacklist::RULE.to_owned()))
|
||||
@@ -723,6 +735,7 @@ fn classify(
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
episodes,
|
||||
);
|
||||
// A release with no size has nothing to say about its size band, so that
|
||||
// term is dropped rather than scored as if it were at the floor. Every
|
||||
@@ -1451,6 +1464,7 @@ mod tests {
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
)
|
||||
.expect("classified release")
|
||||
};
|
||||
@@ -1544,7 +1558,7 @@ mod tests {
|
||||
imdb_id: None,
|
||||
};
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1);
|
||||
|
||||
let classified = classify(
|
||||
release,
|
||||
@@ -1552,6 +1566,7 @@ mod tests {
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
)
|
||||
.expect("classified release");
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
//! - **Status is derived, never stored** (§4.2). Every series the API returns
|
||||
//! carries a status computed from its episodes at request time.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
|
||||
use arr_core::{
|
||||
derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId,
|
||||
SeriesStatus, TitleOverrides,
|
||||
derive_series_status, search_backoff, season_grab_reason, EpisodeId, Language, MediaState,
|
||||
PerEpisodeReason, RootId, SeasonGrabFacts, SeasonId, SeriesId, SeriesStatus, TitleOverrides,
|
||||
};
|
||||
use arr_db::policy::language;
|
||||
use axum::extract::{Path, Query, State};
|
||||
@@ -1360,7 +1360,7 @@ pub async fn episode_releases(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||
load_episode(&state, id).await?;
|
||||
let episode = load_episode(&state, id).await?;
|
||||
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
@@ -1372,7 +1372,8 @@ pub async fn episode_releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::EpisodeNotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
let lengths = season_lengths(&state, episode.series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
@@ -1407,6 +1408,34 @@ pub async fn grab_episode(
|
||||
|
||||
/// Resolves a season by its number within one series, so the deck is
|
||||
/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`).
|
||||
/// Per-season episode counts for one series: the divisor data behind
|
||||
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
|
||||
/// revealed episodes counts zero, which `claimed_episode_count` treats as
|
||||
/// unknown.
|
||||
pub(crate) async fn season_lengths(
|
||||
state: &AppState,
|
||||
series_id: i64,
|
||||
) -> Result<BTreeMap<u32, u32>, ApiError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64"
|
||||
FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id
|
||||
WHERE se.series_id = ?
|
||||
GROUP BY se.number"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
u32::try_from(row.number).unwrap_or_default(),
|
||||
u32::try_from(row.episodes).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result<i64, ApiError> {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
|
||||
@@ -1484,10 +1513,146 @@ pub async fn season_releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::SeasonNotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
let lengths = season_lengths(&state, series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
/// Which lane a season's missing episodes take (#182, §6.2).
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SeasonLane {
|
||||
SeasonPack,
|
||||
PerEpisode,
|
||||
}
|
||||
|
||||
/// What holds a season off the pack lane, in the order that answers the
|
||||
/// operator's question best — the most fundamental reason first (#182).
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PackLaneReason {
|
||||
NoEpisodes,
|
||||
StillAiring,
|
||||
EpisodesOnDisk,
|
||||
PackBackoff,
|
||||
}
|
||||
|
||||
/// Why the season release deck holds what it holds (#182).
|
||||
///
|
||||
/// An empty deck is three different truths, and until this endpoint existed
|
||||
/// the deck could not tell them apart, so it blamed backoff for all three:
|
||||
/// a pack sweep that has not landed yet, a pack sweep that landed and found
|
||||
/// nothing, and a season on the per-episode lane, for which no pack sweep is
|
||||
/// coming at all. The lane says which; `last_pack_search_at` separates the
|
||||
/// first two, exactly as a movie's `last_searched_at` does (#177).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SeasonPackState {
|
||||
pub lane: SeasonLane,
|
||||
/// `None` when the season is on the pack lane.
|
||||
pub reason: Option<PackLaneReason>,
|
||||
/// Failed pack grabs so far. §6.2's backoff step is indexed by this, so
|
||||
/// it is also how long the lane stays quiet (#181).
|
||||
pub pack_failures: i64,
|
||||
/// When the pack lane reopens on its own, RFC3339. `None` unless a
|
||||
/// failure's window is currently open.
|
||||
pub pack_retry_at: Option<String>,
|
||||
/// When a pack sweep for this season last completed. `None` means no
|
||||
/// pack search has ever run, so an empty deck is pending, not settled.
|
||||
pub last_pack_search_at: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/seasons/{season_number}/pack-state", tag = "series",
|
||||
params(
|
||||
("series_id" = i64, Path, description = "Series row id"),
|
||||
("season_number" = i64, Path, description = "Season number, not its row id")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = SeasonPackState),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn season_pack_state(
|
||||
State(state): State<AppState>,
|
||||
Path((series_id, number)): Path<(i64, i64)>,
|
||||
) -> Result<Json<SeasonPackState>, ApiError> {
|
||||
load_series_row(&state, series_id).await?;
|
||||
let season_id = load_season_id(&state, series_id, number).await?;
|
||||
let pool = pool(&state)?;
|
||||
|
||||
let episodes = sqlx::query!(
|
||||
r#"SELECT e.air_date,
|
||||
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 = ?"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let last_pack_search_at = sqlx::query_scalar!(
|
||||
"SELECT last_pack_search_at FROM seasons WHERE id = ?",
|
||||
season_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
// The same failed-pack tally the grab lane backs off on (#181), read
|
||||
// here so the deck can name the window instead of guessing at one.
|
||||
let failed = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let window = chrono::TimeDelta::from_std(search_backoff(failed.failures)).unwrap_or_default();
|
||||
let reopens_at = if failed.failures > 0 {
|
||||
failed
|
||||
.last_failed_at
|
||||
.as_deref()
|
||||
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
|
||||
.map(|at| at.with_timezone(&Utc) + window)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let pack_retry_at = reopens_at.filter(|at| *at > Utc::now());
|
||||
|
||||
let air_dates: Vec<Option<SystemTime>> = episodes
|
||||
.iter()
|
||||
.map(|episode| air_date(episode.air_date.as_deref()))
|
||||
.collect();
|
||||
let reason = season_grab_reason(&SeasonGrabFacts {
|
||||
air_dates: &air_dates,
|
||||
now: SystemTime::now(),
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
|
||||
pack_backoff_active: pack_retry_at.is_some(),
|
||||
});
|
||||
|
||||
Ok(Json(SeasonPackState {
|
||||
lane: if reason.is_some() {
|
||||
SeasonLane::PerEpisode
|
||||
} else {
|
||||
SeasonLane::SeasonPack
|
||||
},
|
||||
reason: reason.map(|reason| match reason {
|
||||
PerEpisodeReason::NoEpisodes => PackLaneReason::NoEpisodes,
|
||||
PerEpisodeReason::StillAiring => PackLaneReason::StillAiring,
|
||||
PerEpisodeReason::EpisodesOnDisk => PackLaneReason::EpisodesOnDisk,
|
||||
PerEpisodeReason::PackBackoff => PackLaneReason::PackBackoff,
|
||||
}),
|
||||
pack_failures: failed.failures,
|
||||
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
|
||||
last_pack_search_at,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series",
|
||||
params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)),
|
||||
@@ -2366,6 +2531,108 @@ mod tests {
|
||||
assert_eq!(after["status"], "complete");
|
||||
}
|
||||
|
||||
/// #182: an empty season deck has to say which empty it is. The lane
|
||||
/// answers "no pack is coming"; `last_pack_search_at` answers "one ran
|
||||
/// and found nothing" — and until both existed the deck blamed backoff
|
||||
/// for every case, including seasons that were never on the pack lane.
|
||||
#[tokio::test]
|
||||
async fn the_pack_state_names_why_a_season_deck_is_empty() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "Pilot", "air_date": "2001-01-01"},
|
||||
{"number": 2, "title": "Second", "air_date": "2001-01-08"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
|
||||
let pool = state.database().expect("database").pool();
|
||||
|
||||
let state_of = |url: String| async move {
|
||||
let response = reqwest::get(url).await.expect("pack state");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("pack state json")
|
||||
};
|
||||
let url = format!("{base}/api/series/{series_id}/seasons/1/pack-state");
|
||||
|
||||
let fresh = state_of(url.clone()).await;
|
||||
assert_eq!(fresh["lane"], "season_pack");
|
||||
assert_eq!(fresh["reason"], serde_json::Value::Null);
|
||||
assert_eq!(fresh["last_pack_search_at"], serde_json::Value::Null);
|
||||
assert_eq!(fresh["pack_failures"], 0);
|
||||
|
||||
// A pack sweep that ran turns an empty deck from pending to settled.
|
||||
sqlx::query(
|
||||
"UPDATE seasons SET last_pack_search_at = '2026-01-01T00:00:00.000Z' WHERE id = ?",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("stamp the sweep");
|
||||
let swept = state_of(url.clone()).await;
|
||||
assert_eq!(swept["lane"], "season_pack");
|
||||
assert_eq!(swept["last_pack_search_at"], "2026-01-01T00:00:00.000Z");
|
||||
|
||||
// §6.2/#181: a failed pack quiets the lane, and the deck says until
|
||||
// when rather than only that it is quiet.
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'pack', 'Bluey S01 1080p WEB-DL', 1000, 'url', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release");
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("failed pack grab");
|
||||
let quiet = state_of(url.clone()).await;
|
||||
assert_eq!(quiet["lane"], "per_episode");
|
||||
assert_eq!(quiet["reason"], "pack_backoff");
|
||||
assert_eq!(quiet["pack_failures"], 1);
|
||||
assert!(
|
||||
quiet["pack_retry_at"].is_string(),
|
||||
"the deck offers a date, not just a closed door"
|
||||
);
|
||||
|
||||
// §14 outranks it: a pack would re-import what is on disk, so
|
||||
// clearing the failure would not earn a pack anyway.
|
||||
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
|
||||
.bind(episode_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("file on disk");
|
||||
let on_disk = state_of(url.clone()).await;
|
||||
assert_eq!(on_disk["reason"], "episodes_on_disk");
|
||||
|
||||
// An unaired episode outranks both.
|
||||
sqlx::query(
|
||||
"UPDATE episodes SET air_date = '2999-01-01' WHERE season_id = ? AND number = 2",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("unair the finale");
|
||||
let airing = state_of(url).await;
|
||||
assert_eq!(airing["reason"], "still_airing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_episode_actions_are_scoped_and_respect_blocked() {
|
||||
let (_dir, state, base) = application().await;
|
||||
|
||||
+141
-18
@@ -4,12 +4,13 @@
|
||||
//! 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.
|
||||
//! season falls back to per-episode while the failure's §6.2 backoff window
|
||||
//! is open, and retries the pack once it elapses — quiet, never off.
|
||||
//!
|
||||
//! 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;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// How a season's missing wanted episodes should be grabbed next.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -31,28 +32,80 @@ pub struct SeasonGrabFacts<'a> {
|
||||
/// 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,
|
||||
/// Whether a failed season-pack grab still holds the season off the
|
||||
/// pack lane — true only while the §6.2 backoff window is open.
|
||||
pub pack_backoff_active: bool,
|
||||
}
|
||||
|
||||
/// Picks the grab mode for one season.
|
||||
/// Why a season is not on the season-pack lane.
|
||||
///
|
||||
/// The season release deck (§9.3) is empty for a season on the per-episode
|
||||
/// lane and stays empty however long it waits, so it has to name which of
|
||||
/// these it is rather than blaming a sweep that is not coming (#182).
|
||||
/// Ordered by how fundamental the answer is: an unaired episode outranks a
|
||||
/// failed pack, because clearing the failure would still not earn a pack.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PerEpisodeReason {
|
||||
/// No episodes are known for the season, so there is nothing to pack.
|
||||
NoEpisodes,
|
||||
/// An episode has not aired, or carries no air date at all.
|
||||
StillAiring,
|
||||
/// §14: a pack would re-import episodes that are already on disk.
|
||||
EpisodesOnDisk,
|
||||
/// §6.2: a failed pack grab's backoff window is still open.
|
||||
PackBackoff,
|
||||
}
|
||||
|
||||
/// Why one season takes the per-episode lane, or `None` when it takes the
|
||||
/// pack lane.
|
||||
///
|
||||
/// 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));
|
||||
pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option<PerEpisodeReason> {
|
||||
if facts.air_dates.is_empty() {
|
||||
return Some(PerEpisodeReason::NoEpisodes);
|
||||
}
|
||||
if !facts
|
||||
.air_dates
|
||||
.iter()
|
||||
.all(|date| date.is_some_and(|date| date <= facts.now))
|
||||
{
|
||||
return Some(PerEpisodeReason::StillAiring);
|
||||
}
|
||||
if facts.any_episode_on_disk {
|
||||
return Some(PerEpisodeReason::EpisodesOnDisk);
|
||||
}
|
||||
if facts.pack_backoff_active {
|
||||
return Some(PerEpisodeReason::PackBackoff);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed {
|
||||
SeasonGrabMode::SeasonPack
|
||||
} else {
|
||||
SeasonGrabMode::PerEpisode
|
||||
/// Picks the grab mode for one season.
|
||||
#[must_use]
|
||||
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
|
||||
match season_grab_reason(facts) {
|
||||
None => SeasonGrabMode::SeasonPack,
|
||||
Some(_) => SeasonGrabMode::PerEpisode,
|
||||
}
|
||||
}
|
||||
|
||||
/// §6.2's targeted-search curve: `1h → 6h → 1d → 3d`, capped at 7d, indexed
|
||||
/// by how many attempts have already been spent.
|
||||
///
|
||||
/// The pack lane counts a season's failed pack grabs as its attempts, so the
|
||||
/// deck can say when the lane reopens rather than only that it is shut.
|
||||
#[must_use]
|
||||
pub fn search_backoff(attempts: i64) -> Duration {
|
||||
match attempts {
|
||||
..=1 => Duration::from_hours(1),
|
||||
2 => Duration::from_hours(6),
|
||||
3 => Duration::from_hours(24),
|
||||
4 => Duration::from_hours(72),
|
||||
_ => Duration::from_hours(24 * 7),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +122,7 @@ mod tests {
|
||||
air_dates,
|
||||
now: SystemTime::UNIX_EPOCH + 100 * DAY,
|
||||
any_episode_on_disk: false,
|
||||
pack_hard_failed: false,
|
||||
pack_backoff_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +158,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_failed_pack_falls_back_to_per_episode() {
|
||||
fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() {
|
||||
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||
let mut facts = facts(&aired);
|
||||
facts.pack_hard_failed = true;
|
||||
facts.pack_backoff_active = true;
|
||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
|
||||
@@ -120,4 +173,74 @@ mod tests {
|
||||
facts.any_episode_on_disk = true;
|
||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
|
||||
/// #182: the deck says which of the four it is, not just "not a pack".
|
||||
#[test]
|
||||
fn the_reason_names_the_condition_that_holds_the_pack_lane_shut() {
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts(&[])),
|
||||
Some(PerEpisodeReason::NoEpisodes)
|
||||
);
|
||||
|
||||
let airing = [
|
||||
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
|
||||
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
|
||||
];
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts(&airing)),
|
||||
Some(PerEpisodeReason::StillAiring)
|
||||
);
|
||||
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts(&undated)),
|
||||
Some(PerEpisodeReason::StillAiring)
|
||||
);
|
||||
|
||||
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||
let mut on_disk = facts(&aired);
|
||||
on_disk.any_episode_on_disk = true;
|
||||
assert_eq!(
|
||||
season_grab_reason(&on_disk),
|
||||
Some(PerEpisodeReason::EpisodesOnDisk)
|
||||
);
|
||||
|
||||
let mut backoff = facts(&aired);
|
||||
backoff.pack_backoff_active = true;
|
||||
assert_eq!(
|
||||
season_grab_reason(&backoff),
|
||||
Some(PerEpisodeReason::PackBackoff)
|
||||
);
|
||||
|
||||
assert_eq!(season_grab_reason(&facts(&aired)), None);
|
||||
}
|
||||
|
||||
/// A failed pack is not the headline when the season could not have had
|
||||
/// a pack anyway — clearing it would change nothing.
|
||||
#[test]
|
||||
fn an_unaired_episode_outranks_a_failed_pack() {
|
||||
let airing = [
|
||||
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
|
||||
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
|
||||
];
|
||||
let mut facts = facts(&airing);
|
||||
facts.pack_backoff_active = true;
|
||||
facts.any_episode_on_disk = true;
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts),
|
||||
Some(PerEpisodeReason::StillAiring)
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2's curve, shared by the pack lane so the deck can say when it
|
||||
/// reopens rather than only that it is shut.
|
||||
#[test]
|
||||
fn the_backoff_curve_climbs_and_caps_at_a_week() {
|
||||
assert_eq!(search_backoff(0), Duration::from_hours(1));
|
||||
assert_eq!(search_backoff(1), Duration::from_hours(1));
|
||||
assert_eq!(search_backoff(2), Duration::from_hours(6));
|
||||
assert_eq!(search_backoff(3), DAY);
|
||||
assert_eq!(search_backoff(4), 3 * DAY);
|
||||
assert_eq!(search_backoff(5), 7 * DAY);
|
||||
assert_eq!(search_backoff(50), 7 * DAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ pub mod status;
|
||||
pub mod tracking;
|
||||
|
||||
pub use arr_parse::NameClaims as ParsedRelease;
|
||||
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
pub use grabbing::{
|
||||
search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, SeasonGrabFacts,
|
||||
SeasonGrabMode,
|
||||
};
|
||||
pub use matching::{
|
||||
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
|
||||
WantedEpisode, WantedMovie,
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct EvaluationContext<'a> {
|
||||
/// when the caller has no size to offer, which abstains rather than
|
||||
/// rejects.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band
|
||||
/// describes one episode, so the size rule divides by this. One for a
|
||||
/// movie or an unknown count; zero is treated as one.
|
||||
pub episode_count: u32,
|
||||
}
|
||||
|
||||
/// A rule's identity when no violation exists to carry concrete evidence.
|
||||
@@ -107,6 +111,7 @@ pub fn evaluate(
|
||||
original_language: &Language,
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: Option<u64>,
|
||||
episode_count: u32,
|
||||
) -> Evaluation {
|
||||
let context = EvaluationContext {
|
||||
policy,
|
||||
@@ -114,6 +119,7 @@ pub fn evaluate(
|
||||
original_language,
|
||||
candidate,
|
||||
size_bytes,
|
||||
episode_count,
|
||||
};
|
||||
let rules: [&dyn PolicyRule; 6] = [
|
||||
&ResolutionRule,
|
||||
@@ -227,7 +233,8 @@ impl PolicyRule for SizeRule {
|
||||
else {
|
||||
return RuleEvaluation::Unknown(RuleKind::Size);
|
||||
};
|
||||
match crate::score::is_below_floor(context.policy, resolution, size) {
|
||||
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count)
|
||||
{
|
||||
None => RuleEvaluation::Unknown(RuleKind::Size),
|
||||
Some(true) => RuleEvaluation::HardFail(Rule::Size),
|
||||
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
|
||||
@@ -515,7 +522,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
evaluate(policy, overrides, &en(), candidate, None).verdict
|
||||
evaluate(policy, overrides, &en(), candidate, None, 1).verdict
|
||||
}
|
||||
|
||||
fn verdict_for(
|
||||
@@ -529,6 +536,7 @@ mod tests {
|
||||
original_language,
|
||||
candidate,
|
||||
None,
|
||||
1,
|
||||
)
|
||||
.verdict
|
||||
}
|
||||
@@ -543,6 +551,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -725,6 +734,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -781,6 +791,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
let expected = if rejected {
|
||||
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
|
||||
@@ -800,6 +811,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -832,6 +844,7 @@ mod tests {
|
||||
original_language: &original,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
};
|
||||
let soft = FixedRule {
|
||||
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
|
||||
@@ -860,6 +873,7 @@ mod tests {
|
||||
original_language: &original,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
};
|
||||
let first = FixedRule {
|
||||
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
|
||||
@@ -995,7 +1009,8 @@ mod tests {
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None
|
||||
None,
|
||||
1
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Eligible
|
||||
@@ -1008,7 +1023,8 @@ mod tests {
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None
|
||||
None,
|
||||
1
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::RequiredAudio)
|
||||
@@ -1082,6 +1098,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
|
||||
@@ -1093,6 +1110,7 @@ mod tests {
|
||||
&Language::PortugueseBrazil,
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
}
|
||||
@@ -1106,6 +1124,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
assert_eq!(
|
||||
|
||||
+263
-14
@@ -19,6 +19,10 @@
|
||||
//! remux playback test in `DESIGN.md` §14; the model is the decision, not the
|
||||
//! constants.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_parse::EpisodeClaim;
|
||||
|
||||
use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
|
||||
|
||||
const BYTES_PER_GIB: i64 = 1 << 30;
|
||||
@@ -84,13 +88,26 @@ pub struct Score {
|
||||
/// resolution the policy carries no band for — scores zero on the dominant
|
||||
/// term rather than being penalised for what the release name failed to say.
|
||||
/// The resolution-rank term treats an unclaimed resolution the same way.
|
||||
///
|
||||
/// A band describes one episode (`DESIGN.md` §5.5), so `episode_count` is the
|
||||
/// divisor that turns a season pack's total into the per-episode figure the
|
||||
/// band is compared against. The caller supplies it — `arr-core` has no IO,
|
||||
/// and a `Season` claim names a season, not a length. A movie, and any release
|
||||
/// whose count is unknown, is one episode; zero is treated as one.
|
||||
#[must_use]
|
||||
pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score {
|
||||
pub fn score(
|
||||
policy: &Policy,
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: u64,
|
||||
seeders: u32,
|
||||
episode_count: u32,
|
||||
) -> Score {
|
||||
let weights = &policy.score_weights;
|
||||
let claimed = candidate.resolution();
|
||||
let per_episode = per_episode_size(size_bytes, episode_count);
|
||||
let size = claimed
|
||||
.and_then(|resolution| policy.size_bands.get(&resolution))
|
||||
.map_or(0, |band| size_points(band, weights, size_bytes));
|
||||
.map_or(0, |band| size_points(band, weights, per_episode));
|
||||
let source = candidate
|
||||
.source()
|
||||
.map_or(0, |source| source_points(policy, source));
|
||||
@@ -113,25 +130,69 @@ pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders
|
||||
///
|
||||
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
|
||||
#[must_use]
|
||||
pub fn score_release(policy: &Policy, release: &Release) -> Score {
|
||||
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score {
|
||||
score(
|
||||
policy,
|
||||
Candidate::PreGrab(&release.parsed),
|
||||
release.size,
|
||||
release.seeders,
|
||||
episode_count,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a size falls below its band's floor, which is a hard filter.
|
||||
///
|
||||
/// The floor takes the same per-episode figure as the target penalty
|
||||
/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor
|
||||
/// would let every pack through untested. Zero `episode_count` is one episode.
|
||||
///
|
||||
/// `None` when the policy carries no band for that resolution: no band is no
|
||||
/// opinion, not a rejection.
|
||||
#[must_use]
|
||||
pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option<bool> {
|
||||
pub fn is_below_floor(
|
||||
policy: &Policy,
|
||||
resolution: Resolution,
|
||||
size_bytes: u64,
|
||||
episode_count: u32,
|
||||
) -> Option<bool> {
|
||||
policy
|
||||
.size_bands
|
||||
.get(&resolution)
|
||||
.map(|band| size_bytes < band.floor_bytes)
|
||||
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
|
||||
}
|
||||
|
||||
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
|
||||
/// behind [`score`] and [`is_below_floor`].
|
||||
///
|
||||
/// `season_lengths` maps season number to that season's episode count as the
|
||||
/// caller knows it — the caller supplies the fact the way `apply_auto_track`
|
||||
/// takes `ever_refreshed`, because `arr-core` cannot look it up. An `Episodes`
|
||||
/// claim knows its own length; a `Season` or `Seasons` claim is summed from
|
||||
/// the map. Anything unknown — no claim, a daily episode, a season the map
|
||||
/// does not carry — falls back to one episode, which measures the release by
|
||||
/// its full size and fails toward rejecting a good pack rather than grabbing
|
||||
/// a bad one.
|
||||
#[must_use]
|
||||
pub fn claimed_episode_count(
|
||||
claim: Option<&EpisodeClaim>,
|
||||
season_lengths: &BTreeMap<u32, u32>,
|
||||
) -> u32 {
|
||||
let count = match claim {
|
||||
Some(EpisodeClaim::Episodes { episodes, .. }) => {
|
||||
u32::try_from(episodes.len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
Some(EpisodeClaim::Season { season }) => season_lengths.get(season).copied().unwrap_or(0),
|
||||
Some(EpisodeClaim::Seasons { first, last }) => (*first..=*last)
|
||||
.map(|season| season_lengths.get(&season).copied().unwrap_or(0))
|
||||
.fold(0u32, u32::saturating_add),
|
||||
Some(EpisodeClaim::Daily { .. }) | None => 1,
|
||||
};
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
/// The per-episode figure every size band is compared against.
|
||||
fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
|
||||
size_bytes / u64::from(episode_count.max(1))
|
||||
}
|
||||
|
||||
/// The size term: a ramp from the floor up to the target, then a penalty that
|
||||
@@ -262,7 +323,13 @@ mod tests {
|
||||
|
||||
fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score {
|
||||
let claims = claims(source);
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
score(
|
||||
&policy(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
/// The same, at a resolution the caller picks.
|
||||
@@ -277,10 +344,20 @@ mod tests {
|
||||
source: Some(source),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
score(
|
||||
&policy(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
fn size_rule(size_bytes: u64) -> RuleEvaluation {
|
||||
size_rule_for(size_bytes, 1)
|
||||
}
|
||||
|
||||
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
|
||||
let policy = policy();
|
||||
let overrides = TitleOverrides::default();
|
||||
let language = Language::Other("en".to_owned());
|
||||
@@ -291,6 +368,7 @@ mod tests {
|
||||
original_language: &language,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: Some(size_bytes),
|
||||
episode_count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,6 +399,7 @@ mod tests {
|
||||
&Language::Other("en".to_owned()),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(gib(60)),
|
||||
1,
|
||||
);
|
||||
|
||||
// A bad score, but a score: nothing filters it out, so a selection
|
||||
@@ -387,10 +466,13 @@ mod tests {
|
||||
assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
|
||||
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3)),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,7 +481,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10);
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1);
|
||||
|
||||
assert_eq!(score.size, 0);
|
||||
assert_eq!(score.total, score.source + score.seeders);
|
||||
@@ -417,8 +499,8 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1);
|
||||
|
||||
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
||||
assert_eq!(under.size, 0);
|
||||
@@ -466,7 +548,7 @@ mod tests {
|
||||
resolution: Some(resolution),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution
|
||||
};
|
||||
|
||||
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
|
||||
@@ -483,7 +565,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10);
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1);
|
||||
|
||||
assert_eq!(unranked.resolution, 0);
|
||||
assert_eq!(unclaimed.resolution, 0);
|
||||
@@ -500,4 +582,171 @@ mod tests {
|
||||
assert!(hd.seeders > uhd.seeders);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
|
||||
/// Issue #180: a movie passes one episode's worth and must score exactly
|
||||
/// as it did before packs learned to divide. Every term pinned to a
|
||||
/// literal so the normalisation cannot quietly move movie scoring.
|
||||
#[test]
|
||||
fn a_movie_scores_exactly_as_before_pack_normalisation() {
|
||||
let at_target = scored(ClaimedSource::WebDl, gib(22), 40);
|
||||
assert_eq!(
|
||||
at_target,
|
||||
Score {
|
||||
total: 1398,
|
||||
size: 1000,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
|
||||
let over_target = scored(ClaimedSource::WebDl, gib(30), 40);
|
||||
assert_eq!(
|
||||
over_target,
|
||||
Score {
|
||||
total: 918,
|
||||
size: 520,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() {
|
||||
let episode = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10);
|
||||
|
||||
assert_eq!(pack.size, episode.size);
|
||||
assert_eq!(pack.total, episode.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_below_the_floor_per_episode_is_rejected() {
|
||||
// 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor
|
||||
// — a pack of mud-quality encodes fails as plainly as one of them.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
size_rule_for(gib(30), 10),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
|
||||
// The same total over three episodes is 10 GiB each and passes.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
|
||||
Some(false)
|
||||
);
|
||||
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_episode_count_falls_back_to_one_episode() {
|
||||
let single = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0);
|
||||
|
||||
assert_eq!(zero, single);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_episode_count_comes_from_the_claim_and_the_season_lengths() {
|
||||
let lengths = BTreeMap::from([(1, 10), (2, 8), (3, 12)]);
|
||||
|
||||
let episodes = EpisodeClaim::Episodes {
|
||||
season: 1,
|
||||
episodes: vec![2, 3, 4],
|
||||
};
|
||||
assert_eq!(claimed_episode_count(Some(&episodes), &lengths), 3);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Season { season: 2 }), &lengths),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Seasons { first: 1, last: 3 }), &lengths),
|
||||
30
|
||||
);
|
||||
|
||||
// Unknowns are one episode: no claim, a daily episode, a season the
|
||||
// map has not revealed yet.
|
||||
assert_eq!(claimed_episode_count(None, &lengths), 1);
|
||||
assert_eq!(
|
||||
claimed_episode_count(
|
||||
Some(&EpisodeClaim::Daily {
|
||||
year: 2024,
|
||||
month: 5,
|
||||
day: 13
|
||||
}),
|
||||
&lengths
|
||||
),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Season { season: 9 }), &lengths),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// The observed case from issue #180: Rick and Morty S08 against a
|
||||
/// TV policy (1080p target 2 GiB, 2160p target 6 GiB, 60 points per GiB
|
||||
/// over, resolution step 300). Measured by pack total, the 1080p pack's
|
||||
/// smaller size penalty beat the 300-point resolution step; per episode,
|
||||
/// 2160p ranks first as `resolution_pref` says it should.
|
||||
#[test]
|
||||
fn the_rick_and_morty_season_pack_ranks_2160p_above_1080p() {
|
||||
let policy = Policy {
|
||||
size_bands: BTreeMap::from([
|
||||
(
|
||||
Resolution::R1080p,
|
||||
SizeBand {
|
||||
floor_bytes: GIB / 4,
|
||||
target_bytes: gib(2),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
(
|
||||
Resolution::R2160p,
|
||||
SizeBand {
|
||||
floor_bytes: gib(1),
|
||||
target_bytes: gib(6),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
]),
|
||||
..self::policy()
|
||||
};
|
||||
let episodes = 10;
|
||||
let hd_pack = (43 * GIB) / 10; // Rick.and.Morty.S08.1080p.x265-ELiTE, 4.3 GiB
|
||||
let uhd_pack = (138 * GIB) / 10; // S08 Complete 2160p HDR, 13.8 GiB
|
||||
|
||||
let scored = |resolution, size| {
|
||||
let claims = NameClaims {
|
||||
resolution: Some(resolution),
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes)
|
||||
};
|
||||
let hd = scored(ClaimedResolution::P1080, hd_pack);
|
||||
let uhd = scored(ClaimedResolution::P2160, uhd_pack);
|
||||
|
||||
// Neither pack trips the floor per episode, so the ranking decides.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
|
||||
Some(false)
|
||||
);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
//! - the `grabs` row is written from that response, so a crash between the add
|
||||
//! and the insert heals on the next tick instead of leaving an orphan.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict};
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::{Language, Policy, TitleOverrides, Verdict};
|
||||
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
|
||||
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
|
||||
use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest};
|
||||
@@ -1198,6 +1199,7 @@ pub(crate) async fn store_release(
|
||||
overrides,
|
||||
original_language,
|
||||
blacklist,
|
||||
1,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
@@ -1224,6 +1226,21 @@ pub(crate) async fn store_episode_release(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
// A size band describes one episode (`DESIGN.md` §5.5): the release is
|
||||
// measured per episode, and a season pack's length comes from the series
|
||||
// the covered episodes belong to. An unrevealed season divides by one.
|
||||
let claim = arr_parse::parse(&release.name).episode;
|
||||
let season_lengths = match episode_ids.first() {
|
||||
Some(&episode_id)
|
||||
if claim
|
||||
.as_ref()
|
||||
.is_some_and(arr_parse::EpisodeClaim::is_season_pack) =>
|
||||
{
|
||||
season_lengths_of(database, episode_id).await?
|
||||
}
|
||||
_ => BTreeMap::new(),
|
||||
};
|
||||
let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths);
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
@@ -1231,6 +1248,7 @@ pub(crate) async fn store_episode_release(
|
||||
overrides,
|
||||
original_language,
|
||||
blacklist,
|
||||
episode_count,
|
||||
)
|
||||
.await?;
|
||||
for episode_id in episode_ids {
|
||||
@@ -1246,6 +1264,35 @@ pub(crate) async fn store_episode_release(
|
||||
Ok((release_id, eligible))
|
||||
}
|
||||
|
||||
/// Per-season episode counts for the series one covered episode belongs to
|
||||
/// (`DESIGN.md` §5.5): the divisor data for season-pack normalisation.
|
||||
async fn season_lengths_of(
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
) -> Result<BTreeMap<u32, u32>, GrabError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64"
|
||||
FROM seasons se
|
||||
LEFT JOIN episodes e ON e.season_id = se.id
|
||||
WHERE se.series_id = (SELECT s2.series_id FROM episodes e2
|
||||
JOIN seasons s2 ON s2.id = e2.season_id
|
||||
WHERE e2.id = ?)
|
||||
GROUP BY se.number"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
u32::try_from(row.number).unwrap_or_default(),
|
||||
u32::try_from(row.episodes).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn classify_and_store(
|
||||
database: &Db,
|
||||
release: &SearchRelease,
|
||||
@@ -1253,6 +1300,7 @@ async fn classify_and_store(
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
episode_count: u32,
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let evaluation = evaluate(
|
||||
@@ -1261,12 +1309,14 @@ async fn classify_and_store(
|
||||
original_language,
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
episode_count,
|
||||
);
|
||||
let scored = score(
|
||||
policy,
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
episode_count,
|
||||
);
|
||||
// A release that did not say its size is not a tiny one: scoring it
|
||||
// against the band's floor would bury it. Same treatment as the manual
|
||||
|
||||
@@ -307,6 +307,7 @@ impl ImportAction {
|
||||
&original_language,
|
||||
Candidate::PostDownload(&feature.media),
|
||||
Some(feature.size),
|
||||
1,
|
||||
);
|
||||
let waiver: Option<Rule> = match evaluation.verdict {
|
||||
Verdict::Rejected(rule) => {
|
||||
@@ -494,6 +495,7 @@ impl ImportAction {
|
||||
&original_language,
|
||||
Candidate::PostDownload(&assignment.file.media),
|
||||
Some(assignment.file.size),
|
||||
1,
|
||||
);
|
||||
let waiver = match evaluation.verdict {
|
||||
Verdict::Rejected(rule) => {
|
||||
|
||||
@@ -32,8 +32,8 @@ use arr_dl::TransmissionClient;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
|
||||
use crate::grab::{
|
||||
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
|
||||
SeedingRules,
|
||||
backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope,
|
||||
GrabTarget, Grabber, SeedingRules,
|
||||
};
|
||||
use crate::indexers::IndexerDirectory;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
@@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
let pack_hard_failed = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
||||
) AS "failed!: bool""#,
|
||||
let failed_packs = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
@@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
.collect::<Vec<_>>(),
|
||||
now: std::time::SystemTime::now(),
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
|
||||
pack_hard_failed,
|
||||
pack_backoff_active: failed_packs.failures > 0
|
||||
&& !backoff_elapsed(
|
||||
failed_packs.failures,
|
||||
failed_packs.last_failed_at.as_deref(),
|
||||
),
|
||||
}) == SeasonGrabMode::SeasonPack)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ impl TvGrabAction {
|
||||
&season,
|
||||
&blacklist,
|
||||
EPISODE_SEARCHES_PER_TICK - episode_searches,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -102,12 +103,15 @@ impl TvGrabAction {
|
||||
|
||||
/// Search and grab for one season. `None` means the season was skipped
|
||||
/// without spending an indexer call — nothing due, or no language yet.
|
||||
/// `ignore_pack_backoff` is the manual trigger's "try again now": it
|
||||
/// waives the failed-pack backoff window for this one invocation.
|
||||
async fn grab_season(
|
||||
&self,
|
||||
database: &Db,
|
||||
season: &PendingSeason,
|
||||
blacklist: &Blacklist,
|
||||
episode_budget: usize,
|
||||
ignore_pack_backoff: bool,
|
||||
) -> Result<Option<SeasonWork>, GrabError> {
|
||||
// §5.2: without the title's original language there is nothing to
|
||||
// evaluate a release against, and guessing is worse than waiting.
|
||||
@@ -141,7 +145,8 @@ impl TvGrabAction {
|
||||
air_dates: &air_dates,
|
||||
now,
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
|
||||
pack_hard_failed: pack_hard_failed(database, season.season_id).await?,
|
||||
pack_backoff_active: !ignore_pack_backoff
|
||||
&& pack_backoff_active(database, season.season_id).await?,
|
||||
});
|
||||
|
||||
let aired_due_gaps: Vec<&SeasonEpisode> = gaps
|
||||
@@ -369,6 +374,10 @@ impl TvGrabAction {
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Vec<TvCandidate>, GrabError> {
|
||||
// #182: a season-scoped sweep is the pack lane's own search, and the
|
||||
// release deck needs to know it completed to tell "nothing found"
|
||||
// from "nothing has run yet".
|
||||
let pack_sweep = matches!(selector, TvSelector::Season { .. });
|
||||
let target = TvTarget {
|
||||
tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
|
||||
title: season.series_title.clone(),
|
||||
@@ -446,6 +455,9 @@ impl TvGrabAction {
|
||||
eligible: stored,
|
||||
});
|
||||
}
|
||||
if pack_sweep && any_searchable {
|
||||
record_pack_search(database, season.season_id).await?;
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
@@ -566,8 +578,10 @@ impl TvGrabAction {
|
||||
}
|
||||
|
||||
/// The manual season trigger (issue #125's deck, drained per #132):
|
||||
/// reset every open episode's backoff and run the same pack-or-fall-back
|
||||
/// lane as the tick, scoped to this one season with no tick budgets.
|
||||
/// reset every open episode's backoff, waive the failed-pack backoff
|
||||
/// (§6.2's escape hatch is this deck), and run the same
|
||||
/// pack-or-fall-back lane as the tick, scoped to this one season with
|
||||
/// no tick budgets.
|
||||
///
|
||||
/// A satisfied season — nothing open, or a grab already in flight —
|
||||
/// gets its deck refreshed and no grab; a blocked series refuses.
|
||||
@@ -625,7 +639,7 @@ impl TvGrabAction {
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX)
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX, true)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1106,19 +1120,37 @@ async fn load_season_release(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether a season-pack grab for this season already hard-failed — the
|
||||
/// fall-back-to-per-episode signal.
|
||||
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let failed = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
||||
) AS "failed!: bool""#,
|
||||
/// When the season's own pack search last completed (#182). Written only by
|
||||
/// a season-scoped sweep, so it answers the deck's question — did a pack
|
||||
/// search run — and nothing else.
|
||||
async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
"UPDATE seasons
|
||||
SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
season_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether failed season-pack grabs still hold this season off the pack
|
||||
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
|
||||
/// (each failed grab is one attempt), it never disables it. Anchored on the
|
||||
/// latest failed grab's `grabbed_at` — failure time itself is not recorded.
|
||||
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
Ok(failed)
|
||||
Ok(row.failures > 0 && !backoff_elapsed(row.failures, row.last_failed_at.as_deref()))
|
||||
}
|
||||
|
||||
/// A pack for exactly this season. Multi-season packs are never selected
|
||||
@@ -1463,38 +1495,41 @@ mod tests {
|
||||
/// The third acceptance case, selection side: a hard-failed pack put the
|
||||
/// release on the blacklist and the season falls back to per-episode —
|
||||
/// the pack is not tried again and the episodes are not written off.
|
||||
#[tokio::test]
|
||||
async fn a_hard_failed_pack_falls_back_to_per_episode() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
// What the import tick left behind: a failed season grab and the
|
||||
// pack release on the blacklist (§6.3).
|
||||
/// Seed what the import tick leaves behind after a pack fails: one
|
||||
/// `failed` season grab per (infohash, age) pair.
|
||||
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
|
||||
'https://tracker/pack.torrent', '{}', 'eligible')
|
||||
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||
'https://tracker/oldpack.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, 'season', ?, 'dead', 'failed')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
arr_db::blacklist::add(
|
||||
database.pool(),
|
||||
Some("dead"),
|
||||
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos",
|
||||
"dolby_vision_profile",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (index, age) in ages.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, 'season', ?, ?, 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.bind(format!("dead{index}"))
|
||||
.bind(age)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed pack inside its §6.2 backoff window stays per-episode: the
|
||||
/// window quiets the pack lane, per-episode still makes progress.
|
||||
#[tokio::test]
|
||||
async fn a_failed_pack_inside_its_window_falls_back_to_per_episode() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
@@ -1520,6 +1555,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2: one failed pack backs the pack lane off for 1h, then the tick
|
||||
/// retries the pack instead of never trying again.
|
||||
#[tokio::test]
|
||||
async fn a_failed_pack_retries_after_its_backoff_window() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-2 hours"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await.last().unwrap(),
|
||||
&("season".to_owned(), season_id, "sent".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
/// Repeated failures ride the capped curve: five failed packs mean a 7d
|
||||
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
||||
#[tokio::test]
|
||||
async fn a_repeatedly_failed_pack_retries_on_the_capped_curve() {
|
||||
let inside = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(
|
||||
&inside.1,
|
||||
inside.2,
|
||||
&["-30 days", "-25 days", "-20 days", "-12 days", "-6 days"],
|
||||
)
|
||||
.await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
action(&indexer, &downloader).tick(&inside.1).await.unwrap();
|
||||
assert!(
|
||||
fake.torrents()
|
||||
.iter()
|
||||
.all(|torrent| !torrent.source.ends_with("pack.torrent")),
|
||||
"6 days into a 7d window the pack lane stays quiet"
|
||||
);
|
||||
|
||||
let elapsed = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(
|
||||
&elapsed.1,
|
||||
elapsed.2,
|
||||
&["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"],
|
||||
)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
action(&indexer, &downloader)
|
||||
.tick(&elapsed.1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
}
|
||||
|
||||
/// The escape hatch (§6.2, issue #181): a manual season search waives
|
||||
/// the failed-pack backoff and retries the pack right now.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_search_waives_the_pack_backoff() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_season_now(&database, season_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await.last().unwrap(),
|
||||
&("season".to_owned(), season_id, "sent".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading", "downloading", "downloading"]
|
||||
);
|
||||
}
|
||||
|
||||
/// §14: a season with an episode already on disk never grabs the pack,
|
||||
/// even when fully released. The remaining episodes come individually.
|
||||
#[tokio::test]
|
||||
@@ -1552,6 +1671,49 @@ mod tests {
|
||||
assert!(sources[1].ends_with("e03.torrent"));
|
||||
}
|
||||
|
||||
/// #182: the season deck cannot tell a queued pack sweep from one that
|
||||
/// ran and found nothing unless the pack search leaves a mark. Episode
|
||||
/// searches must not leave it — a season on the per-episode lane never
|
||||
/// asked for a pack, and pretending otherwise is the lie the deck told.
|
||||
#[tokio::test]
|
||||
async fn only_a_pack_sweep_stamps_the_season() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let stamp = |database: Db| async move {
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT last_pack_search_at FROM seasons WHERE id = ?",
|
||||
)
|
||||
.bind(season_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
assert!(stamp(database.clone()).await.is_none());
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
let after_pack = stamp(database.clone()).await;
|
||||
assert!(
|
||||
after_pack.is_some(),
|
||||
"a fully released season takes the pack lane and its sweep is the deck's evidence"
|
||||
);
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
|
||||
// An airing season is on the per-episode lane: its episode searches
|
||||
// say nothing about whether a pack was ever looked for.
|
||||
let (_other_dir, airing, airing_id) = wanted_season(&["2024-04-11", "2999-01-01"]).await;
|
||||
action(&indexer, &downloader).tick(&airing).await.unwrap();
|
||||
let never: Option<String> =
|
||||
sqlx::query_scalar("SELECT last_pack_search_at FROM seasons WHERE id = ?")
|
||||
.bind(airing_id)
|
||||
.fetch_one(airing.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(never.is_none());
|
||||
}
|
||||
|
||||
/// An empty result backs the whole season off together (§6.2) instead of
|
||||
/// hammering the tracker every 30 s.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- #182. The season release deck cannot tell a queued pack sweep from one
|
||||
-- that ran and found nothing, so it blames backoff for both. Only the season
|
||||
-- knows when its own pack search last completed: episodes' `last_searched_at`
|
||||
-- moves for reasons that have nothing to do with the pack lane, and a season
|
||||
-- on the per-episode lane never touches the pack search at all.
|
||||
ALTER TABLE seasons ADD COLUMN last_pack_search_at TEXT;
|
||||
@@ -619,7 +619,12 @@ mod tests {
|
||||
|
||||
let one_and_a_half_gib = 1536 << 20;
|
||||
assert_eq!(
|
||||
arr_core::score::is_below_floor(&loaded.policy, Resolution::R1080p, one_and_a_half_gib),
|
||||
arr_core::score::is_below_floor(
|
||||
&loaded.policy,
|
||||
Resolution::R1080p,
|
||||
one_and_a_half_gib,
|
||||
1
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
+144
-28
@@ -38,6 +38,7 @@ import {
|
||||
formatAudio,
|
||||
formatHdr,
|
||||
formatResolution,
|
||||
formatRetryWait,
|
||||
formatScore,
|
||||
formatSeeders,
|
||||
formatSize,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
queueSearch,
|
||||
removeMovie,
|
||||
ruleLabel,
|
||||
type SeasonPackState,
|
||||
sweepExpected,
|
||||
totalSize,
|
||||
type WaiveOutcome,
|
||||
@@ -2799,6 +2801,24 @@ function tvReleasesMain(): TvReleasesView {
|
||||
} else {
|
||||
delete statusEl.dataset.tone;
|
||||
}
|
||||
delete statusEl.dataset.action;
|
||||
}
|
||||
|
||||
/**
|
||||
* A status that carries its own way out. A season held off the pack lane
|
||||
* cannot be helped by the head's re-search alone (#181, #182), so the
|
||||
* sentence that explains the wait also offers the retry that ends it.
|
||||
*/
|
||||
function setStatusAction(text: string, label: string, run: () => void) {
|
||||
const action = document.createElement("button");
|
||||
action.type = "button";
|
||||
action.className = "control";
|
||||
action.textContent = label;
|
||||
action.addEventListener("click", run);
|
||||
statusEl.hidden = false;
|
||||
statusEl.replaceChildren(document.createTextNode(text), action);
|
||||
delete statusEl.dataset.tone;
|
||||
statusEl.dataset.action = "";
|
||||
}
|
||||
|
||||
const actions: ReleaseActions = {
|
||||
@@ -2841,23 +2861,93 @@ function tvReleasesMain(): TvReleasesView {
|
||||
return;
|
||||
}
|
||||
if (!paintBuckets(dom, outcome.releases, actions)) {
|
||||
// issue 167: an empty deck sweeps on open instead of describing a sweep
|
||||
if (sweepIfEmpty) {
|
||||
startSweep(current);
|
||||
} else {
|
||||
setStatus("no releases indexed yet — re-search queues a targeted sweep");
|
||||
}
|
||||
clearBuckets(dom);
|
||||
await emptyVerdict(current, ticket, sweepIfEmpty);
|
||||
return;
|
||||
}
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued sweep is done when its releases appear in the table. Nothing
|
||||
* found is indistinguishable from still running, so the wait says so
|
||||
* honestly instead of promising either way.
|
||||
* An empty season deck is three different truths (#182), and until the
|
||||
* season could say which, it blamed backoff for all three: a pack sweep
|
||||
* still running, a pack sweep that ran and found nothing, and a season on
|
||||
* the per-episode lane, where no pack sweep is coming at all. The lane
|
||||
* answers the third; `last_pack_search_at` separates the first two, the
|
||||
* same way a movie's `last_searched_at` does (#177).
|
||||
*/
|
||||
function watchSweep(current: TvDeckRequest, ticket: number) {
|
||||
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
|
||||
const outcome = await current.target.packState?.();
|
||||
if (ticket !== sequence || request !== current) {
|
||||
return;
|
||||
}
|
||||
if (outcome?.kind === "state" && outcome.state.lane === "per_episode") {
|
||||
sweep.disabled = false;
|
||||
describePerEpisode(current, outcome.state);
|
||||
return;
|
||||
}
|
||||
if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) {
|
||||
sweep.disabled = false;
|
||||
setStatus(
|
||||
`no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// issue 167: an empty deck sweeps on open instead of describing a sweep
|
||||
if (sweepIfEmpty) {
|
||||
void startSweep(current, outcome?.kind === "state" ? outcome.state : null);
|
||||
return;
|
||||
}
|
||||
setStatus("no releases indexed yet — re-search queues a targeted sweep");
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the lane instead of the backoff. A season grabbing episode by
|
||||
* episode has no pack to show and never will while the reason holds, so
|
||||
* the deck says which reason it is and where the releases actually are.
|
||||
*/
|
||||
function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) {
|
||||
const elsewhere = "open an episode for its releases";
|
||||
if (state.reason === "no_episodes") {
|
||||
setStatus(
|
||||
"no episodes known for this season yet — a metadata refresh has to find them before anything can be searched",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.reason === "still_airing") {
|
||||
setStatus(
|
||||
`season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.reason === "episodes_on_disk") {
|
||||
setStatus(
|
||||
`episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const failures =
|
||||
state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`;
|
||||
const quiet =
|
||||
state.pack_retry_at === null
|
||||
? "pack search is quiet until its backoff elapses"
|
||||
: `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`;
|
||||
setStatusAction(
|
||||
`${failures} — ${quiet}. episodes are grabbed one at a time meanwhile.`,
|
||||
"retry the pack now",
|
||||
() => {
|
||||
void startSweep(current, state);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sweep is done when its releases appear, or when the season stamps the
|
||||
* pack search it just finished. `baseline` is that stamp as it read before
|
||||
* the sweep was queued: once it moves, an empty deck is a settled answer
|
||||
* rather than a pending one, and `load` says so.
|
||||
*/
|
||||
function watchSweep(current: TvDeckRequest, baseline: string | null, ticket: number) {
|
||||
sweep.disabled = true;
|
||||
setStatus("sweeping indexers…", undefined, true);
|
||||
const deadline = Date.now() + TV_SWEEP_WAIT_MS;
|
||||
@@ -2875,10 +2965,19 @@ function tvReleasesMain(): TvReleasesView {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
const state = await current.target.packState?.();
|
||||
if (ticket !== sequence || request !== current) {
|
||||
return;
|
||||
}
|
||||
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
|
||||
sweep.disabled = false;
|
||||
await emptyVerdict(current, ticket, false);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
sweep.disabled = false;
|
||||
setStatus(
|
||||
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
|
||||
"sweep has not landed yet — nothing has come back from the indexers; results appear here when it does",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2891,23 +2990,40 @@ function tvReleasesMain(): TvReleasesView {
|
||||
}, TV_SWEEP_POLL_MS);
|
||||
}
|
||||
|
||||
/** Queue a targeted sweep, then watch for its releases to land (§6.2). */
|
||||
function startSweep(current: TvDeckRequest) {
|
||||
/**
|
||||
* Queue a targeted sweep, then watch for it to land (§6.2). `known` is the
|
||||
* lane as it read a moment ago, so the watch is only entered when a pack
|
||||
* sweep is actually expected: on the per-episode lane the sweep searches
|
||||
* episodes, and waiting for a pack that is not coming is the lie #182 is
|
||||
* about. A failed pack is the exception — a manual search waives its
|
||||
* window and does try a pack (#181).
|
||||
*/
|
||||
async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) {
|
||||
sweep.disabled = true;
|
||||
void (async () => {
|
||||
const outcome = await current.target.search();
|
||||
if (request !== current) {
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
sweep.disabled = false;
|
||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
sequence += 1;
|
||||
window.clearTimeout(pollTimer);
|
||||
watchSweep(current, sequence);
|
||||
})();
|
||||
const before =
|
||||
known === undefined
|
||||
? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null))
|
||||
: known;
|
||||
if (request !== current) {
|
||||
return;
|
||||
}
|
||||
const outcome = await current.target.search();
|
||||
if (request !== current) {
|
||||
return;
|
||||
}
|
||||
sequence += 1;
|
||||
window.clearTimeout(pollTimer);
|
||||
if (outcome.kind === "error") {
|
||||
sweep.disabled = false;
|
||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") {
|
||||
sweep.disabled = false;
|
||||
setStatus("searching the season's episodes — open an episode for its releases");
|
||||
return;
|
||||
}
|
||||
watchSweep(current, before?.last_pack_search_at ?? null, sequence);
|
||||
}
|
||||
|
||||
sweep.addEventListener("click", () => {
|
||||
@@ -2915,7 +3031,7 @@ function tvReleasesMain(): TvReleasesView {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
startSweep(current);
|
||||
void startSweep(current);
|
||||
});
|
||||
|
||||
function open(next: TvDeckRequest) {
|
||||
|
||||
@@ -95,6 +95,67 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string
|
||||
return `${Math.round(minutes / (24 * 60))} d ago`;
|
||||
}
|
||||
|
||||
/** Which lane a season's missing episodes take (#182, §6.2). */
|
||||
export type SeasonLane = "season_pack" | "per_episode";
|
||||
|
||||
/** What holds a season off the pack lane (#182). */
|
||||
export type PackLaneReason = "no_episodes" | "still_airing" | "episodes_on_disk" | "pack_backoff";
|
||||
|
||||
/**
|
||||
* Why the season deck holds what it holds, from
|
||||
* `/api/series/{id}/seasons/{n}/pack-state`. An empty deck is three states,
|
||||
* not one: a pack sweep still running, a pack sweep that found nothing, and
|
||||
* a season on the per-episode lane, where no pack sweep is coming.
|
||||
*/
|
||||
export interface SeasonPackState {
|
||||
lane: SeasonLane;
|
||||
reason: PackLaneReason | null;
|
||||
pack_failures: number;
|
||||
pack_retry_at: string | null;
|
||||
last_pack_search_at: string | null;
|
||||
}
|
||||
|
||||
export type PackStateOutcome =
|
||||
| { kind: "state"; state: SeasonPackState }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function seasonPackState(
|
||||
seriesId: number,
|
||||
seasonNumber: number,
|
||||
): Promise<PackStateOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}/pack-state`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "state", state: (await response.json()) as SeasonPackState };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `pack_retry_at` as a coarse wait. The deck says how long the lane stays
|
||||
* quiet, so "it retries on its own" is a promise with a date on it.
|
||||
*/
|
||||
export function formatRetryWait(retryAt: string, now = Date.now()): string {
|
||||
const reopens = Date.parse(retryAt);
|
||||
if (Number.isNaN(reopens)) {
|
||||
return "later";
|
||||
}
|
||||
const minutes = Math.round((reopens - now) / 60_000);
|
||||
if (minutes < 2) {
|
||||
return "any moment";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes} min`;
|
||||
}
|
||||
if (minutes < 48 * 60) {
|
||||
return `${Math.round(minutes / 60)} h`;
|
||||
}
|
||||
return `${Math.round(minutes / (24 * 60))} d`;
|
||||
}
|
||||
|
||||
/** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */
|
||||
export interface MovieFile {
|
||||
id: number;
|
||||
|
||||
+14
-2
@@ -3,8 +3,14 @@
|
||||
// (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
import type { MetadataTrailer } from "./movie";
|
||||
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
|
||||
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
|
||||
import type {
|
||||
ActionOutcome,
|
||||
MovieRelease,
|
||||
PackStateOutcome,
|
||||
ReleasesOutcome,
|
||||
WaiveOutcome,
|
||||
} from "./releases";
|
||||
import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases";
|
||||
|
||||
/** §4.2 derived status — displayed, never editable. */
|
||||
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
|
||||
@@ -233,6 +239,11 @@ export interface TvTarget {
|
||||
releases: () => Promise<ReleasesOutcome>;
|
||||
grab: (releaseId: number) => Promise<ActionOutcome>;
|
||||
search: () => Promise<ActionOutcome>;
|
||||
/**
|
||||
* Why the deck is empty (#182). Seasons only: an episode deck has one
|
||||
* lane, so it has nothing to disambiguate.
|
||||
*/
|
||||
packState?: () => Promise<PackStateOutcome>;
|
||||
}
|
||||
|
||||
export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
|
||||
@@ -241,6 +252,7 @@ export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
|
||||
releases: () => fetchJson(`${base}/releases`),
|
||||
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
|
||||
search: () => post(`${base}/search`),
|
||||
packState: () => seasonPackState(seriesId, seasonNumber),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -537,6 +537,17 @@ body {
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
/* a status that carries its own way out keeps the control on the sentence's
|
||||
own left edge, so the explanation reads first and the action follows it
|
||||
(#182). Long enough copy wraps the control to its own line, which is the
|
||||
reading order anyway. */
|
||||
.deck-status[data-action] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* a sweep in flight borrows the rail's probing-lamp idiom, inline */
|
||||
.deck-status .lamp {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user