feat(meta): substitute TBA for unnamed episodes

This commit is contained in:
Miguel Palhas
2026-08-23 20:55:16 +01:00
parent 4762f2612f
commit 23f5781714
4 changed files with 88 additions and 2 deletions
+48
View File
@@ -569,6 +569,7 @@ mod tests {
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*; use super::*;
use arr_meta::UNTITLED_EPISODE;
fn series_body(status: &str) -> serde_json::Value { fn series_body(status: &str) -> serde_json::Value {
json!({ json!({
@@ -1086,6 +1087,53 @@ mod tests {
assert!(!vanished, "the conflict is over once TMDB lists it again"); 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 { async fn season_two_episode(database: &Db) -> i64 {
sqlx::query_scalar( sqlx::query_scalar(
"SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id "SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id
+1
View File
@@ -25,4 +25,5 @@ pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use model::{ pub use model::{
Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series, SeriesSearchResult, Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
UNTITLED_EPISODE,
}; };
+11 -1
View File
@@ -140,6 +140,10 @@ pub struct SeasonSummary {
pub episode_count: u32, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Season { pub struct Season {
pub number: u32, pub number: u32,
@@ -285,7 +289,13 @@ impl From<RawSeason> for Season {
.into_iter() .into_iter()
.map(|episode| Episode { .map(|episode| Episode {
number: episode.episode_number, 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), air_date: episode.air_date.as_deref().and_then(parse_date),
}) })
.collect(), .collect(),
+28 -1
View File
@@ -7,7 +7,7 @@ use {reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _};
use std::time::Duration; use std::time::Duration;
use arr_meta::{Error, TmdbClient}; use arr_meta::{Error, TmdbClient, UNTITLED_EPISODE};
use chrono::NaiveDate; use chrono::NaiveDate;
use wiremock::matchers::{header_exists, method, path, query_param}; use wiremock::matchers::{header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -464,6 +464,33 @@ async fn debug_output_does_not_leak_the_api_key() {
assert!(rendered.contains("redacted"), "{rendered}"); 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 /// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is
/// where TMDB keeps the mapping. /// where TMDB keeps the mapping.
#[tokio::test] #[tokio::test]