Merge #172: search returns titles only

Closes #172
This commit is contained in:
Miguel Palhas
2026-08-24 16:40:44 +01:00
4 changed files with 30 additions and 195 deletions
+28 -73
View File
@@ -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]