From 23f578171452b5c1c9ea64f6bb5de196bf504642 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:55:16 +0100 Subject: [PATCH] feat(meta): substitute TBA for unnamed episodes --- crates/arr-daemon/src/series_refresh.rs | 48 +++++++++++++++++++++++++ crates/arr-meta/src/lib.rs | 1 + crates/arr-meta/src/model.rs | 12 ++++++- crates/arr-meta/tests/tmdb.rs | 29 ++++++++++++++- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 8cf2372..eab1152 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -569,6 +569,7 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; + use arr_meta::UNTITLED_EPISODE; fn series_body(status: &str) -> serde_json::Value { json!({ @@ -1086,6 +1087,53 @@ mod tests { assert!(!vanished, "the conflict is over once TMDB lists it again"); } + /// #153. TMDB has not named an unaired episode yet, so the refresh stores + /// the placeholder instead of an empty string — and #121's guarded update + /// swaps it for the real title once TMDB fills it in. + #[tokio::test] + async fn an_unnamed_episode_stores_the_placeholder_until_tmdb_names_it() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb( + "Returning Series", + season_one_body(&json!([ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"}, + {"episode_number": 2, "name": "", "air_date": null} + ])), + ) + .await; + + action(&server).tick(&database).await.unwrap(); + + let unnamed = season_one_episode(&database, 2).await; + let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?") + .bind(unnamed) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(title, UNTITLED_EPISODE); + + expire_refresh(&database).await; + server.reset().await; + mount( + &server, + "Returning Series", + season_one_body(&json!([ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"}, + {"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"} + ])), + ) + .await; + + action(&server).tick(&database).await.unwrap(); + + let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?") + .bind(unnamed) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(title, "Hospital"); + } + async fn season_two_episode(database: &Db) -> i64 { sqlx::query_scalar( "SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id diff --git a/crates/arr-meta/src/lib.rs b/crates/arr-meta/src/lib.rs index 5c14e4a..e15fcd6 100644 --- a/crates/arr-meta/src/lib.rs +++ b/crates/arr-meta/src/lib.rs @@ -25,4 +25,5 @@ pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_ pub use error::{Error, Result}; pub use model::{ Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series, SeriesSearchResult, + UNTITLED_EPISODE, }; diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index ce681a7..c98608d 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -140,6 +140,10 @@ pub struct SeasonSummary { pub episode_count: u32, } +/// Placeholder for an episode TMDB has not named yet. #121's refresh replaces +/// it once TMDB fills the title in. +pub const UNTITLED_EPISODE: &str = "TBA"; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Season { pub number: u32, @@ -285,7 +289,13 @@ impl From for Season { .into_iter() .map(|episode| Episode { number: episode.episode_number, - title: episode.name, + // TMDB leaves an unaired episode's name empty; a placeholder + // keeps "" out of search, filenames and the compat shim. + title: if episode.name.is_empty() { + UNTITLED_EPISODE.to_owned() + } else { + episode.name + }, air_date: episode.air_date.as_deref().and_then(parse_date), }) .collect(), diff --git a/crates/arr-meta/tests/tmdb.rs b/crates/arr-meta/tests/tmdb.rs index 3aba2da..a93ad0a 100644 --- a/crates/arr-meta/tests/tmdb.rs +++ b/crates/arr-meta/tests/tmdb.rs @@ -7,7 +7,7 @@ use {reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _}; use std::time::Duration; -use arr_meta::{Error, TmdbClient}; +use arr_meta::{Error, TmdbClient, UNTITLED_EPISODE}; use chrono::NaiveDate; use wiremock::matchers::{header_exists, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -464,6 +464,33 @@ async fn debug_output_does_not_leak_the_api_key() { assert!(rendered.contains("redacted"), "{rendered}"); } +/// TMDB leaves an unaired episode's name as `""`. It must not reach the +/// library as an empty string — search haystacks, §7.4 filenames and the +/// compat shim all treat it as real text (#153) — so it becomes a placeholder +/// that a later refresh replaces once TMDB names it. +#[tokio::test] +async fn season_episode_without_a_name_gets_the_placeholder_title() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/3/tv/82728/season/1")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"season_number": 1, "episodes": [ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"}, + {"episode_number": 2, "name": "", "air_date": null} + ]}"#, + )) + .mount(&server) + .await; + + let season = client(&server) + .season(82_728, 1) + .await + .expect("lookup succeeds"); + + assert_eq!(season.episodes[0].title, "Magic Xylophone"); + assert_eq!(season.episodes[1].title, UNTITLED_EPISODE); +} + /// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is /// where TMDB keeps the mapping. #[tokio::test]