From 4b89245f00f3a29a405334a9902e65d59b43fd55 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 16:39:48 +0100 Subject: [PATCH] fix(api): drop episode rows from unified search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search returns titles only (§9.2, amended): the episode branch of the library query, its json_each token machinery, and the SPA's episode row rendering are removed. Deep links to episode releases stay. Fixes #172 --- ...70a7869d1104dfd80dc3a568ef134c4659ca9.json | 62 ----------- crates/arr-api/src/search.rs | 101 +++++------------- web/src/main.ts | 44 +------- web/src/search.ts | 18 +--- 4 files changed, 30 insertions(+), 195 deletions(-) delete mode 100644 .sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json diff --git a/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json b/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json deleted file mode 100644 index bccef76..0000000 --- a/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "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 '\\') 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": [ - { - "name": "episode_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "series_id!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "series_title!: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "tag!: String", - "ordinal": 3, - "type_info": "Null" - }, - { - "name": "title!: String", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "poster_path", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "vote_average", - "ordinal": 6, - "type_info": "Float" - }, - { - "name": "series_tmdb_id!: i64", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - true, - false, - false, - null, - false, - true, - true, - false - ] - }, - "hash": "80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9" -} diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 70c6803..33c82c8 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -33,8 +33,7 @@ pub struct ReleasesQuery { #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SearchResponse { pub kind: SearchInputKind, - /// In-library hits, grouped first (§9.2): movies and series by title, - /// episodes by episode title with their series and `SxxEyy` for context. + /// In-library hits, grouped first (§9.2): movies and series by title. pub library: Vec, pub tmdb: Vec, pub manual: Option, @@ -47,7 +46,6 @@ pub struct SearchResponse { pub enum LibraryResult { Movie(Movie), Series(LibrarySeries), - Episode(LibraryEpisode), } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -65,25 +63,6 @@ pub struct LibrarySeries { pub vote_average: Option, } -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct LibraryEpisode { - pub episode_id: i64, - pub series_id: i64, - pub series_title: String, - /// `SxxEyy`, so the episode title reads in context (§9.2). - pub tag: String, - /// The episode title — what the search matched on. - pub title: String, - /// The series' poster — an episode has no artwork of its own worth - /// showing at row size. - pub poster_path: Option, - /// The series' rating, out of 10; `null` when TMDB has no votes for it. - pub vote_average: Option, - /// The series' TMDB id — the episode row's trailer chip resolves through - /// it (#148); an episode has no videos of its own worth listing. - pub series_tmdb_id: i64, -} - #[derive(Debug, Clone, Copy, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum SearchInputKind { @@ -196,7 +175,7 @@ pub async fn search( let tokens = like_tokens(input); // §9.2 keeps the two result sets grouped and the library first, so each - // kind lands in its own block: movies, then series, then episodes. + // kind lands in its own block: movies, then series. let mut library: Vec = if matches!(kind, SearchInputKind::TmdbId) { let tmdb_id = input .strip_prefix("tmdb:") @@ -232,16 +211,6 @@ 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 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)); - let tmdb = tmdb_client(&state)?; let results = search_tmdb(&tmdb, kind, input).await?; if matches!(kind, SearchInputKind::ImdbId) { @@ -641,10 +610,9 @@ fn input_kind(input: &str) -> SearchInputKind { } } -/// §9.2's example — `bluey hospital` finding the episode — needs word-wise -/// matching, not one literal phrase. The query's tokens travel as a JSON -/// array the queries walk with `json_each`; LIKE metacharacters are escaped -/// here rather than in SQL. +/// A multi-word title query needs word-wise matching, not one literal +/// phrase. The query's tokens travel as a JSON array the queries walk with +/// `json_each`; LIKE metacharacters are escaped here rather than in SQL. fn like_tokens(input: &str) -> serde_json::Value { serde_json::Value::Array( input @@ -889,11 +857,12 @@ mod tests { assert_eq!(response["tmdb"][0]["vote_count"], 5000); } - /// §9.2: the in-library set matches series titles and episode titles, so - /// `bluey hospital` finds the episode with its series and `SxxEyy` along - /// for context — and plain `bluey` finds the series itself. + /// §9.2, amended: episode rows never appear, under any query. A query + /// matching an episode title and nothing else returns no rows for that + /// series beyond the series itself, and a query naming both the series + /// and one of its episode titles still surfaces only the series row. #[tokio::test] - async fn library_results_cover_series_and_episode_titles() { + async fn library_results_never_include_episode_rows() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) @@ -941,26 +910,6 @@ mod tests { .await .expect("episode"); - let response: serde_json::Value = - reqwest::get(format!("{base}/api/search?q=bluey%20hospital")) - .await - .expect("search") - .json() - .await - .expect("json"); - assert_eq!(response["library"].as_array().expect("library").len(), 1); - let episode = &response["library"][0]; - assert_eq!(episode["kind"], "episode"); - assert_eq!(episode["series_title"], "Bluey"); - assert_eq!(episode["tag"], "S01E02"); - assert_eq!(episode["title"], "Hospital"); - // TMDB carries the TV result below the library set. - 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") @@ -971,19 +920,25 @@ mod tests { 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); + // TMDB carries the TV result below the library set. + assert_eq!(response["tmdb"][0]["kind"], "series"); + assert_eq!(response["tmdb"][0]["title"], "Bluey"); - // 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"); + // A query matching only the episode's title, not the series title, + // returns no rows for that series — no episode row, and no series + // row either, since the series title alone does not match. + for query in ["hospital", "bluey%20hospital"] { + let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q={query}")) + .await + .expect("search") + .json() + .await + .expect("json"); + assert!( + response["library"].as_array().expect("library").is_empty(), + "query: {query}" + ); + } } #[tokio::test] diff --git a/web/src/main.ts b/web/src/main.ts index 38bc21b..871d2f3 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -65,7 +65,6 @@ import { addSeries, allRoots, fetchMovie, - type LibraryEpisodeHit, type LibraryMovie, type LibrarySeriesHit, parseManualInput, @@ -558,17 +557,13 @@ function searchMain( return; } - // §9.2: in-library hits read as whatever they are — a movie, a series, - // or an episode with its series and SxxEyy for context + // §9.2: in-library hits read as whatever they are — a movie or a series. const libraryMovies = response.library.filter( (hit): hit is LibraryMovie => hit.kind === "movie", ); const librarySeries = response.library.filter( (hit): hit is LibrarySeriesHit => hit.kind === "series", ); - const libraryEpisodes = response.library.filter( - (hit): hit is LibraryEpisodeHit => hit.kind === "episode", - ); const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id)); if (response.library.length === 0 && response.tmdb.length === 0) { setStatus("no matches in library or on tmdb"); @@ -585,9 +580,6 @@ function searchMain( for (const series of librarySeries) { refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries)); } - for (const episode of libraryEpisodes) { - refs.groups.library.rows.append(episodeRow(episode, openSeries)); - } } if (response.tmdb.length > 0) { refs.groups.tmdb.section.hidden = false; @@ -967,40 +959,6 @@ function librarySeriesRow( return item; } -/** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */ -function episodeRow( - episode: LibraryEpisodeHit, - open: (id: number, origin: HTMLElement) => void, -): HTMLLIElement { - const { item, row, body, chips } = richRow(); - chips.append( - chip(episode.tag, (span) => { - span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`); - }), - ); - // the episode title is a name, not a readout: sans, never mono - const title = document.createElement("span"); - title.className = "row-sub"; - title.textContent = episode.title; - chips.append(title); - const rating = ratingChip(episode.vote_average); - if (rating !== null) { - chips.append(rating); - } - chips.append(trailerChip("tv", episode.series_tmdb_id)); - const affordance = document.createElement("span"); - affordance.className = "row-add readout"; - affordance.textContent = "episodes"; - chips.append(affordance); - body.append(rowTitle(episode.series_title, null), chips); - row.append(rowPoster(episode.poster_path, episode.series_title), body); - row.addEventListener("click", () => { - open(episode.series_id, row); - }); - item.append(row); - return item; -} - /** A TMDB hit of either kind — the row opens the add flow (§9.2). */ function tmdbRow( hit: TmdbMovie | TmdbSeries, diff --git a/web/src/search.ts b/web/src/search.ts index 2ca9f23..896454c 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -36,23 +36,7 @@ export interface LibrarySeriesHit { vote_average: number | null; } -export interface LibraryEpisodeHit { - kind: "episode"; - episode_id: number; - series_id: number; - series_title: string; - /** `SxxEyy`, for context next to the episode title. */ - tag: string; - /** The episode title — what the search matched on. */ - title: string; - /** The series' poster — an episode has no artwork of its own worth showing at row size. */ - poster_path: string | null; - vote_average: number | null; - /** The series' TMDB id — what the row's trailer chip resolves through. */ - series_tmdb_id: number; -} - -export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit; +export type LibraryResult = LibraryMovie | LibrarySeriesHit; export interface TmdbMovie { kind: "movie";