fix(api): drop episode rows from unified search

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
This commit is contained in:
Miguel Palhas
2026-08-24 16:39:48 +01:00
parent 205855fa7f
commit 4b89245f00
4 changed files with 30 additions and 195 deletions
@@ -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"
}
+28 -73
View File
@@ -33,8 +33,7 @@ pub struct ReleasesQuery {
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResponse { pub struct SearchResponse {
pub kind: SearchInputKind, pub kind: SearchInputKind,
/// In-library hits, grouped first (§9.2): movies and series by title, /// In-library hits, grouped first (§9.2): movies and series by title.
/// episodes by episode title with their series and `SxxEyy` for context.
pub library: Vec<LibraryResult>, pub library: Vec<LibraryResult>,
pub tmdb: Vec<TmdbResult>, pub tmdb: Vec<TmdbResult>,
pub manual: Option<String>, pub manual: Option<String>,
@@ -47,7 +46,6 @@ pub struct SearchResponse {
pub enum LibraryResult { pub enum LibraryResult {
Movie(Movie), Movie(Movie),
Series(LibrarySeries), Series(LibrarySeries),
Episode(LibraryEpisode),
} }
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
@@ -65,25 +63,6 @@ pub struct LibrarySeries {
pub vote_average: Option<f64>, pub vote_average: Option<f64>,
} }
#[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<String>,
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// 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)] #[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SearchInputKind { pub enum SearchInputKind {
@@ -196,7 +175,7 @@ pub async fn search(
let tokens = like_tokens(input); let tokens = like_tokens(input);
// §9.2 keeps the two result sets grouped and the library first, so each // §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<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) { let mut library: Vec<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) {
let tmdb_id = input let tmdb_id = input
.strip_prefix("tmdb:") .strip_prefix("tmdb:")
@@ -232,16 +211,6 @@ pub async fn search(
}; };
library.extend(series_rows.into_iter().map(LibraryResult::Series)); 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 tmdb = tmdb_client(&state)?;
let results = search_tmdb(&tmdb, kind, input).await?; let results = search_tmdb(&tmdb, kind, input).await?;
if matches!(kind, SearchInputKind::ImdbId) { 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 /// A multi-word title query needs word-wise matching, not one literal
/// matching, not one literal phrase. The query's tokens travel as a JSON /// phrase. The query's tokens travel as a JSON array the queries walk with
/// array the queries walk with `json_each`; LIKE metacharacters are escaped /// `json_each`; LIKE metacharacters are escaped here rather than in SQL.
/// here rather than in SQL.
fn like_tokens(input: &str) -> serde_json::Value { fn like_tokens(input: &str) -> serde_json::Value {
serde_json::Value::Array( serde_json::Value::Array(
input input
@@ -889,11 +857,12 @@ mod tests {
assert_eq!(response["tmdb"][0]["vote_count"], 5000); assert_eq!(response["tmdb"][0]["vote_count"], 5000);
} }
/// §9.2: the in-library set matches series titles and episode titles, so /// §9.2, amended: episode rows never appear, under any query. A query
/// `bluey hospital` finds the episode with its series and `SxxEyy` along /// matching an episode title and nothing else returns no rows for that
/// for context — and plain `bluey` finds the series itself. /// 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] #[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 tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await; let prowlarr = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
@@ -941,26 +910,6 @@ mod tests {
.await .await
.expect("episode"); .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")) let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
.await .await
.expect("search") .expect("search")
@@ -971,19 +920,25 @@ mod tests {
assert_eq!(library.len(), 1); assert_eq!(library.len(), 1);
assert_eq!(library[0]["kind"], "series"); assert_eq!(library[0]["kind"], "series");
assert_eq!(library[0]["title"], "Bluey"); 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. // A query matching only the episode's title, not the series title,
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital")) // returns no rows for that series — no episode row, and no series
.await // row either, since the series title alone does not match.
.expect("search") for query in ["hospital", "bluey%20hospital"] {
.json() let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q={query}"))
.await .await
.expect("json"); .expect("search")
let library = response["library"].as_array().expect("library"); .json()
assert_eq!(library.len(), 1); .await
assert_eq!(library[0]["kind"], "episode"); .expect("json");
assert_eq!(library[0]["title"], "Hospital"); assert!(
response["library"].as_array().expect("library").is_empty(),
"query: {query}"
);
}
} }
#[tokio::test] #[tokio::test]
+1 -43
View File
@@ -65,7 +65,6 @@ import {
addSeries, addSeries,
allRoots, allRoots,
fetchMovie, fetchMovie,
type LibraryEpisodeHit,
type LibraryMovie, type LibraryMovie,
type LibrarySeriesHit, type LibrarySeriesHit,
parseManualInput, parseManualInput,
@@ -558,17 +557,13 @@ function searchMain(
return; return;
} }
// §9.2: in-library hits read as whatever they are — a movie, a series, // §9.2: in-library hits read as whatever they are — a movie or a series.
// or an episode with its series and SxxEyy for context
const libraryMovies = response.library.filter( const libraryMovies = response.library.filter(
(hit): hit is LibraryMovie => hit.kind === "movie", (hit): hit is LibraryMovie => hit.kind === "movie",
); );
const librarySeries = response.library.filter( const librarySeries = response.library.filter(
(hit): hit is LibrarySeriesHit => hit.kind === "series", (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)); const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id));
if (response.library.length === 0 && response.tmdb.length === 0) { if (response.library.length === 0 && response.tmdb.length === 0) {
setStatus("no matches in library or on tmdb"); setStatus("no matches in library or on tmdb");
@@ -585,9 +580,6 @@ function searchMain(
for (const series of librarySeries) { for (const series of librarySeries) {
refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries)); 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) { if (response.tmdb.length > 0) {
refs.groups.tmdb.section.hidden = false; refs.groups.tmdb.section.hidden = false;
@@ -967,40 +959,6 @@ function librarySeriesRow(
return item; 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). */ /** A TMDB hit of either kind — the row opens the add flow (§9.2). */
function tmdbRow( function tmdbRow(
hit: TmdbMovie | TmdbSeries, hit: TmdbMovie | TmdbSeries,
+1 -17
View File
@@ -36,23 +36,7 @@ export interface LibrarySeriesHit {
vote_average: number | null; vote_average: number | null;
} }
export interface LibraryEpisodeHit { export type LibraryResult = LibraryMovie | LibrarySeriesHit;
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 interface TmdbMovie { export interface TmdbMovie {
kind: "movie"; kind: "movie";