From 2c293e7f6003d5295abfd544424dc87f40aa32a3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:59:18 +0100 Subject: [PATCH] fix(api): episode rows need a token beyond series title Per amended DESIGN.md 9.2, an episode row now also requires at least one query token to match the episode title on its own, so a series-title-only query lists the series and no episodes. --- ...0a7869d1104dfd80dc3a568ef134c4659ca9.json} | 6 ++-- crates/arr-api/src/search.rs | 31 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) rename .sqlx/{query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json => query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json} (83%) diff --git a/.sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json b/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json similarity index 83% rename from .sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json rename to .sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json index c0b913e..bccef76 100644 --- a/.sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json +++ b/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id", + "query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id", "describe": { "columns": [ { @@ -45,7 +45,7 @@ } ], "parameters": { - "Right": 1 + "Right": 2 }, "nullable": [ true, @@ -58,5 +58,5 @@ false ] }, - "hash": "2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2" + "hash": "80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9" } diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index f38b36c..70c6803 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -233,9 +233,11 @@ pub async fn search( library.extend(series_rows.into_iter().map(LibraryResult::Series)); // §9.2 names the TV case explicitly: `bluey hospital` finds the episode. - // Tokens may split across the series and episode titles, so the haystack - // is both joined and every token must land somewhere in it. - let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens) + // Tokens may split across the series and episode titles, so every token + // must land in the concatenation — and at least one must match the + // episode title on its own, or a series-title-only query would list + // every episode the series has. + let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens) .fetch_all(database.pool()) .await?; library.extend(episode_rows.into_iter().map(LibraryResult::Episode)); @@ -956,15 +958,32 @@ mod tests { assert_eq!(response["tmdb"][0]["kind"], "series"); assert_eq!(response["tmdb"][0]["title"], "Bluey"); + // §9.2, amended: an episode row surfaces only when the query matches + // something beyond the series title. `bluey` alone returns the series + // row and no episode below it. let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey")) .await .expect("search") .json() .await .expect("json"); - assert_eq!(response["library"][0]["kind"], "series"); - assert_eq!(response["library"][0]["title"], "Bluey"); - assert_eq!(response["library"][0]["tmdb_id"], 82_728); + let library = response["library"].as_array().expect("library"); + assert_eq!(library.len(), 1); + assert_eq!(library[0]["kind"], "series"); + assert_eq!(library[0]["title"], "Bluey"); + assert_eq!(library[0]["tmdb_id"], 82_728); + + // An episode-title-only query still finds the episode. + let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital")) + .await + .expect("search") + .json() + .await + .expect("json"); + let library = response["library"].as_array().expect("library"); + assert_eq!(library.len(), 1); + assert_eq!(library[0]["kind"], "episode"); + assert_eq!(library[0]["title"], "Hospital"); } #[tokio::test]