@@ -224,14 +224,23 @@ pub async fn search(
|
||||
let results = search_tmdb(&tmdb, kind, input).await?;
|
||||
if matches!(kind, SearchInputKind::ImdbId) {
|
||||
for result in &results {
|
||||
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(LibraryResult::Movie(found));
|
||||
match result {
|
||||
TmdbResult::Movie(movie) => {
|
||||
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(LibraryResult::Movie(found));
|
||||
}
|
||||
}
|
||||
TmdbResult::Series(series) => {
|
||||
let tmdb_id = i64::from(series.tmdb_id);
|
||||
if let Some(found) = 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 = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Series(found));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,14 +325,24 @@ async fn search_tmdb(
|
||||
}
|
||||
matches
|
||||
}
|
||||
SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await {
|
||||
Ok(movies) => movies
|
||||
SearchInputKind::ImdbId => {
|
||||
let found = tmdb
|
||||
.find_by_imdb(input)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
let mut results: Vec<TmdbResult> = found
|
||||
.movies
|
||||
.into_iter()
|
||||
.map(|movie| TmdbResult::Movie(movie.into()))
|
||||
.collect(),
|
||||
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
|
||||
Err(error) => return Err(upstream_error(&error)),
|
||||
},
|
||||
.collect();
|
||||
results.extend(
|
||||
found
|
||||
.series
|
||||
.into_iter()
|
||||
.map(|series| TmdbResult::Series(series.into())),
|
||||
);
|
||||
results
|
||||
}
|
||||
SearchInputKind::Text => {
|
||||
let mut results: Vec<TmdbResult> = tmdb
|
||||
.search_movies(input, None)
|
||||
@@ -950,6 +969,52 @@ mod tests {
|
||||
assert_eq!(response["tmdb"][0]["title"], "1917");
|
||||
}
|
||||
|
||||
/// §9.2: a pasted `tt` id resolves a series exactly as a TMDB id does —
|
||||
/// a hit on TMDB and, when tracked, in the library set too.
|
||||
#[tokio::test]
|
||||
async fn an_imdb_id_resolves_to_a_series() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/find/tt7614372"))
|
||||
.and(query_param("external_source", "imdb_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"movie_results": [],
|
||||
"tv_results": [{
|
||||
"id": 82_728, "name": "Bluey", "original_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");
|
||||
sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?)",
|
||||
)
|
||||
.bind(root_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("series");
|
||||
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=tt7614372"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["kind"], "imdb_id");
|
||||
assert_eq!(response["library"][0]["kind"], "series");
|
||||
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_releases_are_classified_and_name_rejection_rules() {
|
||||
let tmdb = MockServer::start().await;
|
||||
|
||||
@@ -9,8 +9,8 @@ use serde::de::DeserializeOwned;
|
||||
use crate::cache::Cache;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{
|
||||
ExternalIds, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, RawSearchPage,
|
||||
RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
|
||||
ExternalIds, FindResults, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie,
|
||||
RawSearchPage, RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
|
||||
};
|
||||
|
||||
/// TMDB's v3 API root.
|
||||
@@ -94,14 +94,20 @@ impl TmdbClient {
|
||||
|
||||
/// Resolve an `IMDb` title id through TMDB's external-id index.
|
||||
///
|
||||
/// TMDB answers with both kinds in one response, so one call surfaces
|
||||
/// whichever the id names — the way a raw TMDB id resolves (§9.2).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Any of [`Error`]; see its variants for what callers should distinguish.
|
||||
pub async fn find_movie_by_imdb(&self, imdb_id: &str) -> Result<Vec<MovieSearchResult>> {
|
||||
pub async fn find_by_imdb(&self, imdb_id: &str) -> Result<FindResults> {
|
||||
let path = format!("find/{}", imdb_id.trim());
|
||||
let params = [("external_source", "imdb_id".to_owned())];
|
||||
let page: RawFindPage = self.get_json(&path, ¶ms).await?;
|
||||
Ok(page.movie_results.into_iter().map(Into::into).collect())
|
||||
Ok(FindResults {
|
||||
movies: page.movie_results.into_iter().map(Into::into).collect(),
|
||||
series: page.tv_results.into_iter().map(Into::into).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a TVDB series id through TMDB's external-id index.
|
||||
|
||||
@@ -24,5 +24,5 @@ mod model;
|
||||
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
|
||||
pub use error::{Error, Result};
|
||||
pub use model::{
|
||||
Episode, ExternalIds, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
|
||||
Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
|
||||
};
|
||||
|
||||
@@ -293,6 +293,15 @@ impl From<RawSeason> for Season {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one `IMDb` id resolved to. An id names one title, so at most one of
|
||||
/// the two lists is non-empty — but TMDB answers both kinds in the same
|
||||
/// response, and both are surfaced rather than filtered here.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FindResults {
|
||||
pub movies: Vec<MovieSearchResult>,
|
||||
pub series: Vec<SeriesSearchResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawFindPage {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"movie_results": [],
|
||||
"tv_results": [
|
||||
{
|
||||
"id": 82728,
|
||||
"name": "Bluey",
|
||||
"original_name": "Bluey",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-10-01",
|
||||
"overview": "The slice-of-life adventures of an Australian cattle dog.",
|
||||
"poster_path": "/58PmSsz6PEdlVscLE1tRJ7tknU.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json");
|
||||
const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json");
|
||||
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
|
||||
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
|
||||
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
|
||||
|
||||
fn client(server: &MockServer) -> TmdbClient {
|
||||
TmdbClient::builder("test-key")
|
||||
@@ -483,6 +484,33 @@ async fn series_external_ids_carries_the_tvdb_id() {
|
||||
assert_eq!(ids.tvdb_id, Some(361_391));
|
||||
}
|
||||
|
||||
/// §9.2: a pasted `tt` id must resolve a series the same way it resolves a
|
||||
/// movie. TMDB answers `/find` with both kinds in one response; the `tv_results`
|
||||
/// half is what this reads.
|
||||
#[tokio::test]
|
||||
async fn find_by_imdb_resolves_series() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/find/tt7614372"))
|
||||
.and(query_param("external_source", "imdb_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(FIND_IMDB_SERIES))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let results = client(&server)
|
||||
.find_by_imdb("tt7614372")
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert!(results.movies.is_empty());
|
||||
assert_eq!(results.series.len(), 1);
|
||||
let series = &results.series[0];
|
||||
assert_eq!(series.tmdb_id, 82_728);
|
||||
assert_eq!(series.title, "Bluey");
|
||||
assert_eq!(series.year(), Some(2018));
|
||||
}
|
||||
|
||||
/// A series TMDB has no TVDB id for stays null rather than zero, so callers
|
||||
/// can tell "unknown" from a real id and fall back to the text query.
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user