Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f7373d73d |
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +90,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5"
|
||||
"hash": "3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +90,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152"
|
||||
"hash": "9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series WHERE id = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +90,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32"
|
||||
"hash": "fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420"
|
||||
}
|
||||
@@ -208,6 +208,9 @@ struct SeriesRow {
|
||||
poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
vote_average: Option<f64>,
|
||||
/// NULL until a metadata refresh has stamped it (#160): the fact the
|
||||
/// auto-track rule needs to tell a seeding refresh from a later one.
|
||||
metadata_refreshed_at: Option<String>,
|
||||
}
|
||||
|
||||
struct EpisodeRow {
|
||||
@@ -353,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Epi
|
||||
}
|
||||
|
||||
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series WHERE id = ?"#, id)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?"#, id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::SeriesNotFound)
|
||||
@@ -407,11 +410,11 @@ pub async fn list(
|
||||
Query(query): Query<ListSeriesQuery>,
|
||||
) -> Result<Json<Vec<Series>>, ApiError> {
|
||||
let rows = if let Some(owner_id) = query.owner_id {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series ORDER BY title, year, id"#)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
};
|
||||
@@ -765,6 +768,9 @@ pub async fn create_season(
|
||||
|
||||
// §4.1. The request does not say whether the episodes are wanted; the
|
||||
// series' auto_track rule does, through the one function that owns it.
|
||||
// #160: whether a refresh has happened yet rides in from the row — a
|
||||
// hand-added season on a never-refreshed series is still seeding, and
|
||||
// §4.1 does not track what was there at add time.
|
||||
let mut revealed = [RefreshedSeason {
|
||||
season: arr_core::Season {
|
||||
id: SeasonId(0),
|
||||
@@ -790,7 +796,11 @@ pub async fn create_season(
|
||||
.collect(),
|
||||
is_new: true,
|
||||
}];
|
||||
apply_auto_track(&core_series(&series), &mut revealed);
|
||||
apply_auto_track(
|
||||
&core_series(&series),
|
||||
series.metadata_refreshed_at.is_some(),
|
||||
&mut revealed,
|
||||
);
|
||||
let [revealed] = revealed;
|
||||
|
||||
// One transaction: a rejected episode must not leave the season behind,
|
||||
@@ -1457,15 +1467,21 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/// #160. A season added before the series' first metadata refresh is part
|
||||
/// of the back catalogue at add time, so the rule does not track it. Once
|
||||
/// a refresh has happened, a hand-revealed season is genuinely new and
|
||||
/// arrives tracked with its episodes wanted.
|
||||
#[tokio::test]
|
||||
async fn auto_track_decides_whether_a_new_season_arrives_wanted() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "kids").await;
|
||||
|
||||
let tracked = add_series(&base, root_id, true).await;
|
||||
let tracked_id = tracked["id"].as_i64().expect("id");
|
||||
// Seeding: no refresh has ever stamped this series.
|
||||
let season = add_season(
|
||||
&base,
|
||||
tracked["id"].as_i64().expect("id"),
|
||||
tracked_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
|
||||
@@ -1473,6 +1489,33 @@ mod tests {
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(season["tracked"], false);
|
||||
assert!(
|
||||
season["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == false),
|
||||
"§4.1: the back catalogue at add time is never auto-tracked"
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(tracked_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("stamp refreshed");
|
||||
let season = add_season(
|
||||
&base,
|
||||
tracked_id,
|
||||
2,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "Dance Mode", "air_date": "2019-04-01"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(season["tracked"], true);
|
||||
assert!(
|
||||
season["episodes"]
|
||||
@@ -1480,7 +1523,7 @@ mod tests {
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == true),
|
||||
"§4.1: auto_track marks the episodes of a revealed season wanted"
|
||||
"§4.1: from the second refresh onward the rule tracks what is new"
|
||||
);
|
||||
|
||||
let untracked_id = sqlx::query_scalar::<_, i64>(
|
||||
|
||||
@@ -13,8 +13,14 @@ pub struct RefreshedSeason {
|
||||
/// Existing seasons keep their tracking rule and leaf-level intent unchanged.
|
||||
/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may
|
||||
/// still track it by hand.
|
||||
pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
|
||||
if !series.auto_track {
|
||||
///
|
||||
/// `ever_refreshed` says whether the series has had a metadata refresh yet
|
||||
/// (`metadata_refreshed_at IS NOT NULL`). The caller supplies the fact because
|
||||
/// `arr-core` has no IO; seasons revealed by a series' first refresh are its
|
||||
/// back catalogue as it stood at add time, and §4.1 never tracks those — the
|
||||
/// rule applies from the second refresh onward.
|
||||
pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [RefreshedSeason]) {
|
||||
if !series.auto_track || !ever_refreshed {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +120,7 @@ mod tests {
|
||||
refreshed_season(3, true, &[false, false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(true), &mut refresh);
|
||||
apply_auto_track(&series(true), true, &mut refresh);
|
||||
|
||||
assert!(!refresh[0].season.tracked);
|
||||
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
|
||||
@@ -122,6 +128,39 @@ mod tests {
|
||||
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
|
||||
}
|
||||
|
||||
/// #160. The first refresh of a newly added series reveals the whole back
|
||||
/// catalogue at once; §4.1 never tracks those.
|
||||
#[test]
|
||||
fn the_seeding_refresh_tracks_nothing() {
|
||||
let mut refresh = [
|
||||
refreshed_season(1, true, &[false, false]),
|
||||
refreshed_season(2, true, &[false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(true), false, &mut refresh);
|
||||
|
||||
assert!(refresh.iter().all(|refreshed| !refreshed.season.tracked));
|
||||
assert!(refresh
|
||||
.iter()
|
||||
.all(|refreshed| refreshed.episodes.iter().all(|episode| !episode.wanted)));
|
||||
}
|
||||
|
||||
/// #160. From the second refresh onward a season that genuinely did not
|
||||
/// exist when the series was added is tracked, and its episodes arrive
|
||||
/// wanted — while the seasons already on record stay as they were.
|
||||
#[test]
|
||||
fn a_later_refresh_tracks_a_genuinely_new_season() {
|
||||
let mut refresh = [
|
||||
refreshed_season(1, false, &[true, true]),
|
||||
refreshed_season(3, true, &[false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(true), true, &mut refresh);
|
||||
|
||||
assert!(refresh[1].season.tracked);
|
||||
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untracked_series_keeps_manual_leaf_intent_only() {
|
||||
let mut refresh = [
|
||||
@@ -129,7 +168,7 @@ mod tests {
|
||||
refreshed_season(3, true, &[false, false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(false), &mut refresh);
|
||||
apply_auto_track(&series(false), true, &mut refresh);
|
||||
|
||||
assert!(refresh[0].episodes.iter().all(|episode| episode.wanted));
|
||||
assert!(!refresh[0].season.tracked);
|
||||
@@ -141,7 +180,7 @@ mod tests {
|
||||
fn auto_track_skips_season_zero() {
|
||||
let mut refresh = [refreshed_season(0, true, &[false])];
|
||||
|
||||
apply_auto_track(&series(true), &mut refresh);
|
||||
apply_auto_track(&series(true), true, &mut refresh);
|
||||
|
||||
assert!(!refresh[0].season.tracked);
|
||||
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
|
||||
|
||||
@@ -168,7 +168,13 @@ impl SeriesRefreshAction {
|
||||
});
|
||||
}
|
||||
}
|
||||
apply_auto_track(&core_series(stale), &mut revealed);
|
||||
// #160: the fact of a previous refresh rides in from the row, so the
|
||||
// seeding refresh never tracks the back catalogue it reveals.
|
||||
apply_auto_track(
|
||||
&core_series(stale),
|
||||
stale.metadata_refreshed_at.is_some(),
|
||||
&mut revealed,
|
||||
);
|
||||
for refreshed in revealed {
|
||||
self.insert_revealed_season(&mut transaction, stale.id, refreshed)
|
||||
.await?;
|
||||
@@ -779,8 +785,11 @@ mod tests {
|
||||
assert_eq!(vote, Some(8.417));
|
||||
}
|
||||
|
||||
/// #160. A series' first refresh reveals its back catalogue, but §4.1
|
||||
/// never tracks what was already there at add time: nothing is tracked,
|
||||
/// nothing arrives wanted.
|
||||
#[tokio::test]
|
||||
async fn first_refresh_reveals_seasons_and_applies_auto_track() {
|
||||
async fn the_seeding_refresh_reveals_but_tracks_nothing() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
|
||||
|
||||
@@ -792,14 +801,14 @@ mod tests {
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, true), (2, true)]);
|
||||
assert_eq!(rows, vec![(1, false), (2, false)]);
|
||||
let wanted: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM episodes WHERE wanted = 1 AND state = 'missing'",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wanted, 3);
|
||||
assert_eq!(wanted, 0);
|
||||
// #120 backfill: the series was added before it had an id on file.
|
||||
let tvdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tvdb_id FROM series WHERE tmdb_id = 82728")
|
||||
@@ -816,6 +825,50 @@ mod tests {
|
||||
assert_eq!(stamped, 1);
|
||||
}
|
||||
|
||||
/// #160. Once the series has been refreshed before, a season TMDB reveals
|
||||
/// that the library has never seen genuinely appeared after add time, so
|
||||
/// it is tracked and its episodes arrive wanted.
|
||||
#[tokio::test]
|
||||
async fn a_later_refresh_tracks_a_genuinely_new_season() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = MockServer::start().await;
|
||||
mount_with(&server, "Returning Series", &[1]).await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let tracked: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE tracked = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tracked, 0, "the seeding refresh tracks nothing");
|
||||
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount(
|
||||
&server,
|
||||
"Returning Series",
|
||||
season_one_body(&two_episodes()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = action(&server).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
let rows: Vec<(i64, bool)> =
|
||||
sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, false), (2, true)]);
|
||||
let wanted: Vec<i64> = sqlx::query_scalar(
|
||||
"SELECT e.number FROM episodes e JOIN seasons s ON s.id = e.season_id
|
||||
WHERE s.number = 2 AND e.wanted = 1",
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wanted, vec![1]);
|
||||
}
|
||||
|
||||
/// `auto_track` is a rule about reveals, not intent: without it nothing
|
||||
/// arrives wanted, even though everything is revealed.
|
||||
#[tokio::test]
|
||||
@@ -877,13 +930,13 @@ mod tests {
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, true), (2, true)]);
|
||||
assert_eq!(rows, vec![(1, false), (2, false)]);
|
||||
let counts: (i64, i64) =
|
||||
sqlx::query_as("SELECT count(*), coalesce(sum(wanted), 0) FROM episodes")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(counts, (3, 3));
|
||||
assert_eq!(counts, (3, 0));
|
||||
}
|
||||
|
||||
/// Episodes revealed into an existing season follow that season's rule:
|
||||
|
||||
Reference in New Issue
Block a user