Merge #127: unified search covers series and episodes

Closes #127
This commit is contained in:
Miguel Palhas
2026-08-23 17:41:22 +01:00
8 changed files with 565 additions and 41 deletions
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\\' ORDER BY title, year, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
"describe": {
"columns": [
{
@@ -88,5 +88,5 @@
true
]
},
"hash": "540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226"
"hash": "0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE title LIKE ? ESCAPE '\\' ORDER BY title, year, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
"describe": {
"columns": [
{
@@ -88,5 +88,5 @@
true
]
},
"hash": "f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b"
"hash": "7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "blocked!: bool",
"ordinal": 6,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863"
}
@@ -0,0 +1,44 @@
{
"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\" 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",
"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"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
null,
false
]
},
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "blocked!: bool",
"ordinal": 6,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7"
}
+293 -28
View File
@@ -33,11 +33,45 @@ pub struct ReleasesQuery {
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResponse {
pub kind: SearchInputKind,
pub library: Vec<Movie>,
pub tmdb: Vec<TmdbMovie>,
/// 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 tmdb: Vec<TmdbResult>,
pub manual: Option<String>,
}
/// One in-library hit. Tagged so a client can branch on what it found
/// without re-deriving it from fields.
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LibraryResult {
Movie(Movie),
Series(LibrarySeries),
Episode(LibraryEpisode),
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct LibrarySeries {
pub id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
pub original_language: Option<String>,
pub root_id: i64,
pub blocked: bool,
}
#[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,
}
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SearchInputKind {
@@ -59,6 +93,23 @@ pub struct TmdbMovie {
pub poster_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TmdbResult {
Movie(TmdbMovie),
Series(TmdbSeries),
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TmdbSeries {
pub tmdb_id: u32,
pub title: String,
pub original_language: String,
pub year: Option<i32>,
pub overview: Option<String>,
pub poster_path: Option<String>,
}
/// One release's score, kept as its terms so the UI can explain a ranking
/// (`DESIGN.md` §5.5) rather than showing a bare number.
#[derive(Debug, Clone, Copy, Default, Serialize, ToSchema)]
@@ -122,31 +173,65 @@ pub async fn search(
}
let database = state.database().ok_or(ApiError::Unavailable)?;
let mut library = if matches!(kind, SearchInputKind::TmdbId) {
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.
let mut library: Vec<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) {
let tmdb_id = input
.strip_prefix("tmdb:")
.unwrap_or(input)
.trim()
.parse::<i64>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
let pattern = format!("%{}%", escape_like(input));
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, tmdb_id, pattern)
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
.fetch_all(database.pool()).await?
.into_iter()
.map(LibraryResult::Movie)
.collect()
} else {
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
.fetch_all(database.pool()).await?
.into_iter()
.map(LibraryResult::Movie)
.collect()
};
let series_rows = if matches!(kind, SearchInputKind::TmdbId) {
let tmdb_id = input
.strip_prefix("tmdb:")
.unwrap_or(input)
.trim()
.parse::<i64>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
.fetch_all(database.pool()).await?
} else {
let pattern = format!("%{}%", escape_like(input));
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, pattern)
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
.fetch_all(database.pool()).await?
};
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" 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)
.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) {
for result in &results {
let tmdb_id = i64::from(result.tmdb_id);
if let Some(movie) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
let TmdbResult::Movie(movie) = result else {
continue;
};
let tmdb_id = i64::from(movie.tmdb_id);
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
.fetch_optional(database.pool()).await?
{
library.push(movie);
library.push(LibraryResult::Movie(found));
}
}
}
@@ -159,11 +244,24 @@ pub async fn search(
}))
}
/// Movie and series TMDB ids live in separate namespaces, so deduplication
/// keys on the kind as well as the id.
fn already_matched(matches: &[TmdbResult], candidate: &TmdbResult) -> bool {
let (is_series, tmdb_id) = match candidate {
TmdbResult::Movie(movie) => (false, movie.tmdb_id),
TmdbResult::Series(series) => (true, series.tmdb_id),
};
matches.iter().any(|existing| match existing {
TmdbResult::Movie(movie) => !is_series && movie.tmdb_id == tmdb_id,
TmdbResult::Series(series) => is_series && series.tmdb_id == tmdb_id,
})
}
async fn search_tmdb(
tmdb: &arr_meta::TmdbClient,
kind: SearchInputKind,
input: &str,
) -> Result<Vec<TmdbMovie>, ApiError> {
) -> Result<Vec<TmdbResult>, ApiError> {
let results = match kind {
SearchInputKind::TmdbId => {
let id = input
@@ -172,10 +270,11 @@ async fn search_tmdb(
.trim()
.parse::<u32>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
// §9.2: a raw TMDB id resolves against both movie and TV.
let mut matches = match tmdb.movie(id).await {
Ok(movie) => {
let year = movie.year();
vec![TmdbMovie {
vec![TmdbResult::Movie(TmdbMovie {
tmdb_id: movie.tmdb_id,
title: movie.title,
original_title: movie.original_title,
@@ -183,41 +282,65 @@ async fn search_tmdb(
year,
overview: movie.overview,
poster_path: movie.poster_path,
}]
})]
}
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
Err(error) => return Err(upstream_error(&error)),
};
match tmdb.series(id).await {
Ok(series) => matches.push(TmdbResult::Series(series.into())),
Err(arr_meta::Error::NotFound { .. }) => {}
Err(error) => return Err(upstream_error(&error)),
}
if !input.starts_with("tmdb:") {
for movie in tmdb
.search_movies(input, None)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(TmdbMovie::from)
{
if !matches
.iter()
.any(|candidate| candidate.tmdb_id == movie.tmdb_id)
{
matches.push(movie);
let candidate = TmdbResult::Movie(movie.into());
if !already_matched(&matches, &candidate) {
matches.push(candidate);
}
}
for series in tmdb
.search_series(input)
.await
.map_err(|error| upstream_error(&error))?
{
let candidate = TmdbResult::Series(series.into());
if !already_matched(&matches, &candidate) {
matches.push(candidate);
}
}
}
matches
}
SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await {
Ok(movies) => movies.into_iter().map(TmdbMovie::from).collect(),
Ok(movies) => movies
.into_iter()
.map(|movie| TmdbResult::Movie(movie.into()))
.collect(),
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
Err(error) => return Err(upstream_error(&error)),
},
SearchInputKind::Text => tmdb
.search_movies(input, None)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(TmdbMovie::from)
.collect(),
SearchInputKind::Text => {
let mut results: Vec<TmdbResult> = tmdb
.search_movies(input, None)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(|movie| TmdbResult::Movie(movie.into()))
.collect();
results.extend(
tmdb.search_series(input)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(|series| TmdbResult::Series(series.into())),
);
results
}
SearchInputKind::Magnet | SearchInputKind::TorrentUrl => Vec::new(),
};
Ok(results)
@@ -420,6 +543,34 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
}
}
impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
fn from(series: arr_meta::SeriesSearchResult) -> Self {
let year = series.year();
Self {
tmdb_id: series.tmdb_id,
title: series.title,
original_language: series.original_language,
year,
overview: series.overview,
poster_path: series.poster_path,
}
}
}
impl From<arr_meta::Series> for TmdbSeries {
fn from(series: arr_meta::Series) -> Self {
let year = series.year();
Self {
tmdb_id: series.tmdb_id,
title: series.title,
original_language: series.original_language,
year,
overview: series.overview,
poster_path: series.poster_path,
}
}
}
fn input_kind(input: &str) -> SearchInputKind {
let lower = input.to_ascii_lowercase();
if lower.starts_with("magnet:?") {
@@ -441,6 +592,19 @@ 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.
fn like_tokens(input: &str) -> serde_json::Value {
serde_json::Value::Array(
input
.split_whitespace()
.map(|token| serde_json::Value::String(escape_like(token)))
.collect(),
)
}
fn escape_like(input: &str) -> String {
input
.replace('\\', "\\\\")
@@ -638,6 +802,13 @@ mod tests {
)
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (438631, 'Dune', 2021, 'en', 2)")
.execute(state.database().expect("database").pool()).await.expect("movie");
@@ -649,10 +820,92 @@ mod tests {
.await
.expect("json");
assert_eq!(response["kind"], "text");
assert_eq!(response["library"][0]["kind"], "movie");
assert_eq!(response["library"][0]["title"], "Dune");
assert_eq!(response["tmdb"][0]["kind"], "movie");
assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134);
}
/// §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.
#[tokio::test]
async fn library_results_cover_series_and_episode_titles() {
let tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search/movie"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
"id": 82_728, "name": "Bluey", "original_language": "en",
"first_air_date": "2018-10-01"
}]})),
)
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
let pool = state.database().expect("database").pool();
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
.fetch_one(pool)
.await
.expect("TV root");
let series_id: i64 = sqlx::query_scalar(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?) RETURNING id",
)
.bind(root_id)
.fetch_one(pool)
.await
.expect("series");
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id",
)
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')")
.bind(season_id)
.execute(pool)
.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");
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);
}
#[tokio::test]
async fn numeric_titles_survive_a_missing_tmdb_id() {
let tmdb = MockServer::start().await;
@@ -662,6 +915,11 @@ mod tests {
.respond_with(ResponseTemplate::new(404))
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/tv/1917"))
.respond_with(ResponseTemplate::new(404))
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/movie"))
.and(query_param("query", "1917"))
@@ -671,6 +929,13 @@ mod tests {
})))
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (530915, '1917', 2019, 'en', 2)")
.execute(state.database().expect("database").pool()).await.expect("movie");
+11 -4
View File
@@ -406,7 +406,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
return;
}
const inLibrary = new Set(response.library.map((movie) => movie.tmdb_id));
// Series and episode hits render in issue 130; movies keep the current row.
const libraryMovies = response.library.filter(
(hit): hit is LibraryMovie => hit.kind === "movie",
);
const inLibrary = new Set(libraryMovies.map((movie) => movie.tmdb_id));
if (response.library.length === 0 && response.tmdb.length === 0) {
setStatus("no matches in library or on tmdb");
return;
@@ -416,7 +420,7 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
if (response.library.length > 0) {
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of response.library) {
for (const movie of libraryMovies) {
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
@@ -428,8 +432,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
if (response.tmdb.length > 0) {
refs.groups.tmdb.section.hidden = false;
refs.groups.tmdb.count.textContent = String(response.tmdb.length);
for (const movie of response.tmdb) {
refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots));
for (const hit of response.tmdb) {
if (hit.kind !== "movie") {
continue;
}
refs.groups.tmdb.rows.append(tmdbRow(hit, inLibrary.has(hit.tmdb_id), fetchRoots));
}
}
}
+101 -5
View File
@@ -1,10 +1,11 @@
// Hand-written mirror of arr-api's /api/search, /api/roots and POST
// /api/movies schemas — same reasoning as health.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
// Hand-written mirror of arr-api's /api/search, /api/roots, POST /api/movies
// and POST /api/series schemas — same reasoning as health.ts: the generated
// client (src/api/) is uncommitted, so CI's tsc cannot see it.
export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url";
export interface LibraryMovie {
kind: "movie";
id: number;
tmdb_id: number;
title: string;
@@ -18,7 +19,32 @@ export interface LibraryMovie {
waiver?: unknown;
}
export interface LibrarySeriesHit {
kind: "series";
id: number;
tmdb_id: number;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
blocked: boolean;
}
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;
}
export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit;
export interface TmdbMovie {
kind: "movie";
tmdb_id: number;
title: string;
original_title: string;
@@ -27,10 +53,21 @@ export interface TmdbMovie {
overview: string | null;
}
export interface TmdbSeries {
kind: "series";
tmdb_id: number;
title: string;
original_language: string;
year: number | null;
overview: string | null;
}
export type TmdbResult = TmdbMovie | TmdbSeries;
export interface SearchResponse {
kind: SearchInputKind;
library: LibraryMovie[];
tmdb: TmdbMovie[];
library: LibraryResult[];
tmdb: TmdbResult[];
manual: string | null;
}
@@ -97,6 +134,29 @@ export async function movieRoots(): Promise<Root[]> {
return (await allRoots()).filter((root) => root.kind === "movie");
}
/** The TV roots. The series add flow pre-fills from these. */
export async function tvRoots(): Promise<Root[]> {
return (await allRoots()).filter((root) => root.kind === "tv");
}
/** The stored series a successful POST /api/series returns. */
export interface ApiSeries {
id: number;
tmdb_id: number;
tvdb_id: number | null;
title: string;
year: number | null;
original_language: string | null;
root_id: number;
auto_track: boolean;
overrides: Record<string, unknown>;
upstream_ended: boolean;
blocked: boolean;
status: string;
wanted_episodes: number;
available_episodes: number;
}
export type AddOutcome =
| { kind: "added"; movie: LibraryMovie }
| { kind: "conflict" }
@@ -127,6 +187,42 @@ export async function addMovie(movie: TmdbMovie, rootId: number): Promise<AddOut
}
}
export type AddSeriesOutcome =
| { kind: "added"; series: ApiSeries }
| { kind: "conflict" }
| { kind: "error"; detail: string };
/** Add a series to a TV root, with the §4.1 initial auto_track choice. */
export async function addSeries(
series: TmdbSeries,
rootId: number,
autoTrack: boolean,
): Promise<AddSeriesOutcome> {
try {
const response = await fetch("/api/series", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tmdb_id: series.tmdb_id,
title: series.title,
year: series.year,
original_language: series.original_language,
root_id: rootId,
auto_track: autoTrack,
}),
});
if (response.status === 409) {
return { kind: "conflict" };
}
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "added", series: (await response.json()) as ApiSeries };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };