From f78735b0875a0f37102dad975f720d386dffe23f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 21:15:39 +0100 Subject: [PATCH] fix(api): address search review feedback --- ...13cc21e26c9cace9a0e2180cca42219cfce3.json} | 8 +- crates/arr-api/src/search.rs | 150 +++++++++++++----- 2 files changed, 111 insertions(+), 47 deletions(-) rename .sqlx/{query-2a1c86807011c8dfba5583d7172e8fdfdd2db6e52695e6f4980d1285c9964959.json => query-8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3.json} (92%) diff --git a/.sqlx/query-2a1c86807011c8dfba5583d7172e8fdfdd2db6e52695e6f4980d1285c9964959.json b/.sqlx/query-8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3.json similarity index 92% rename from .sqlx/query-2a1c86807011c8dfba5583d7172e8fdfdd2db6e52695e6f4980d1285c9964959.json rename to .sqlx/query-8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3.json index b1905e1..14d3224 100644 --- a/.sqlx/query-2a1c86807011c8dfba5583d7172e8fdfdd2db6e52695e6f4980d1285c9964959.json +++ b/.sqlx/query-8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3.json @@ -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 FROM movies WHERE tmdb_id = ? ORDER BY 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 FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\\' ORDER BY title, year, id", "describe": { "columns": [ { @@ -65,10 +65,10 @@ } ], "parameters": { - "Right": 1 + "Right": 2 }, "nullable": [ - true, + false, false, false, true, @@ -82,5 +82,5 @@ true ] }, - "hash": "2a1c86807011c8dfba5583d7172e8fdfdd2db6e52695e6f4980d1285c9964959" + "hash": "8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3" } diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index a6a17d2..0b3ecad 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -145,7 +145,8 @@ pub async fn search( .trim() .parse::() .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; - 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 FROM movies WHERE tmdb_id = ? ORDER BY id"#, tmdb_id) + 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 FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, tmdb_id, pattern) .fetch_all(database.pool()).await? } else { let pattern = format!("%{}%", escape_like(input)); @@ -154,45 +155,7 @@ pub async fn search( }; let tmdb = tmdb_client(&state)?; - let results = match kind { - SearchInputKind::TmdbId => { - let id = input - .strip_prefix("tmdb:") - .unwrap_or(input) - .trim() - .parse::() - .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; - let movie = tmdb - .movie(id) - .await - .map_err(|error| upstream_error(&error))?; - let year = movie.year(); - vec![TmdbMovie { - tmdb_id: movie.tmdb_id, - title: movie.title, - original_title: movie.original_title, - original_language: movie.original_language, - year, - overview: movie.overview, - poster_path: movie.poster_path, - }] - } - SearchInputKind::ImdbId => tmdb - .find_movie_by_imdb(input) - .await - .map_err(|error| upstream_error(&error))? - .into_iter() - .map(TmdbMovie::from) - .collect(), - SearchInputKind::Text => tmdb - .search_movies(input, None) - .await - .map_err(|error| upstream_error(&error))? - .into_iter() - .map(TmdbMovie::from) - .collect(), - SearchInputKind::Magnet | SearchInputKind::TorrentUrl => Vec::new(), - }; + 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); @@ -212,6 +175,70 @@ pub async fn search( })) } +async fn search_tmdb( + tmdb: &arr_meta::TmdbClient, + kind: SearchInputKind, + input: &str, +) -> Result, ApiError> { + let results = match kind { + SearchInputKind::TmdbId => { + let id = input + .strip_prefix("tmdb:") + .unwrap_or(input) + .trim() + .parse::() + .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; + let mut matches = match tmdb.movie(id).await { + Ok(movie) => { + let year = movie.year(); + vec![TmdbMovie { + tmdb_id: movie.tmdb_id, + title: movie.title, + original_title: movie.original_title, + original_language: movie.original_language, + year, + overview: movie.overview, + poster_path: movie.poster_path, + }] + } + Err(arr_meta::Error::NotFound { .. }) => Vec::new(), + 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); + } + } + } + matches + } + SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await { + Ok(movies) => movies.into_iter().map(TmdbMovie::from).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::Magnet | SearchInputKind::TorrentUrl => Vec::new(), + }; + Ok(results) +} + #[utoipa::path( get, path = "/api/releases", tag = "search", params(ReleasesQuery), responses( @@ -294,9 +321,14 @@ pub async fn releases( SearchRequest::Text { .. } if indexer.capabilities.search.available => request.clone(), _ => continue, }; - if let Ok(releases) = prowlarr.search_indexer(indexer.id, &indexer_request).await { - for release in releases { - classified.push(classify(release, &policy, &overrides, &original_language)?); + match prowlarr.search_indexer(indexer.id, &indexer_request).await { + Ok(releases) => { + for release in releases { + classified.push(classify(release, &policy, &overrides, &original_language)?); + } + } + Err(error) => { + tracing::warn!(indexer_id = indexer.id, %error, "manual release search failed"); } } } @@ -630,6 +662,38 @@ mod tests { assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134); } + #[tokio::test] + async fn numeric_titles_survive_a_missing_tmdb_id() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/movie/1917")) + .respond_with(ResponseTemplate::new(404)) + .mount(&tmdb) + .await; + Mock::given(method("GET")) + .and(path("/search/movie")) + .and(query_param("query", "1917")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": [{"id": 530_915, "title": "1917", "original_title": "1917", + "original_language": "en", "release_date": "2019-12-25"}] + }))) + .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"); + + let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=1917")) + .await + .expect("search") + .json() + .await + .expect("json"); + assert_eq!(response["library"][0]["title"], "1917"); + assert_eq!(response["tmdb"][0]["title"], "1917"); + } + #[tokio::test] async fn manual_releases_are_classified_and_name_rejection_rules() { let tmdb = MockServer::start().await;