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:
-62
@@ -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"
|
||||
}
|
||||
@@ -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<LibraryResult>,
|
||||
pub tmdb: Vec<TmdbResult>,
|
||||
pub manual: Option<String>,
|
||||
@@ -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<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)]
|
||||
#[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<LibraryResult> = 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]
|
||||
|
||||
+1
-43
@@ -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,
|
||||
|
||||
+1
-17
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user