fix(api): address search review feedback
ci / rust (pull_request) Successful in 1m45s
ci / web (pull_request) Successful in 55s

This commit is contained in:
Miguel Palhas
2026-08-22 21:15:39 +01:00
parent cab0d33964
commit f78735b087
2 changed files with 111 additions and 47 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 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"
}
+107 -43
View File
@@ -145,7 +145,8 @@ pub async fn search(
.trim()
.parse::<i64>()
.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::<u32>()
.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<Vec<TmdbMovie>, ApiError> {
let results = match kind {
SearchInputKind::TmdbId => {
let id = input
.strip_prefix("tmdb:")
.unwrap_or(input)
.trim()
.parse::<u32>()
.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;