+2
-2
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+56
@@ -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"
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
+56
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user