@@ -727,6 +727,15 @@ pub async fn create_season(
|
|||||||
"episode number cannot be negative".into(),
|
"episode number cannot be negative".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// The column rejects the empty string (#153); say so before the database
|
||||||
|
// has to.
|
||||||
|
if input
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.any(|episode| episode.title.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(ApiError::Invalid("episode title cannot be empty".into()));
|
||||||
|
}
|
||||||
let mut numbers: Vec<i64> = input
|
let mut numbers: Vec<i64> = input
|
||||||
.episodes
|
.episodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1402,6 +1411,28 @@ mod tests {
|
|||||||
assert_eq!(season["episodes"][0]["wanted"], false);
|
assert_eq!(season["episodes"][0]["wanted"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #153. An empty episode title is rejected up front — the column and the
|
||||||
|
/// TMDB boundary both refuse it, so the API must too.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_empty_episode_title_is_rejected() {
|
||||||
|
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");
|
||||||
|
|
||||||
|
for title in ["", " "] {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/series/{series_id}/seasons"))
|
||||||
|
.json(&serde_json::json!({"number": 1, "episodes": [
|
||||||
|
{"number": 1, "title": title}
|
||||||
|
]}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create season");
|
||||||
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_rejected_season_leaves_nothing_behind_to_retry_over() {
|
async fn a_rejected_season_leaves_nothing_behind_to_retry_over() {
|
||||||
let (_dir, state, base) = application().await;
|
let (_dir, state, base) = application().await;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
|
||||||
|
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
|
||||||
|
-- search haystack, §7.4 filenames and the compat shim as if they were real
|
||||||
|
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
|
||||||
|
-- boundary now substitutes — #121's guarded update replaces it once TMDB
|
||||||
|
-- fills the title in.
|
||||||
|
--
|
||||||
|
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
|
||||||
|
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
|
||||||
|
-- a transaction, where that pragma is a no-op); enforcement lives in code.
|
||||||
|
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
|
||||||
@@ -24,5 +24,6 @@ mod model;
|
|||||||
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
|
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user