feat(meta): TMDB search and movie lookup #49
Merged
naps62-yolo
merged 4 commits from 2026-08-22 19:52:58 +01:00
issue/14-tmdb into main
Labels
Clear labels
area/api
area/ci
area/compat
area/core
area/daemon
area/db
area/dl
area/indexer
area/infra
area/meta
area/parse
area/probe
area/subs
area/web
difficulty/easy
difficulty/hard
difficulty/moderate
difficulty/trivial
phase/1-skeleton
phase/2-logic
phase/3-sourcing
phase/4-movies
phase/5-ui
phase/6-tv
phase/7-people
phase/8-compat
phase/9-subtitles
type/bug
type/chore
type/feature
type/test
arr-api
CI and test harness
arr-compat
arr-core
arr-daemon
arr-db
arr-dl
arr-indexer
workspace and tooling
arr-meta
arr-parse
arr-probe
arr-subs crate: providers, extraction, translation, sync
web/
bounded, obvious approach, few files
subtle correctness or cross-cutting
multiple files, judgement, an interface to design
one file, mechanical, no design decisions
workspace, CI, config, database, empty API and SPA
parsing and policy engine, pure, no network
TMDB and Prowlarr, read-only
movies end to end
search, buckets, library views, queues
seasons, episodes, tracking, derived status
owner tags and notifications
Jellyseerr shim
build order: subtitles, DESIGN.md §15
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: yolo/arr#49
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #14.
arr-metagets movie search and detail lookup against TMDB.original_languagearrives as bare ISO 639-1, which has one code for both Portuguese variants, so the dub rule in §5.2 cannot be expressed against it alone.Moviealso carriesorigin_countries— fromorigin_country, falling back toproduction_countries— which is what separates a Brazilian film from a Portuguese one.Digital release dates come back appended to the detail call rather than costing a second round trip, reduced to the earliest type-4 entry across every country: a release existing in one region is a release that exists on the indexers.
Movie::is_digitally_released(on)puts the §6.2 gate in one tested place, so #26 does not have to re-derive it.Responses are cached in process for a day, matching the metadata refresh tick (§8). Failures are never cached — an outage must not pin a title into a bad state until tomorrow. The cache is in-memory rather than in SQLite because
arr-dbis #4 and still open; §8's daily cadence is satisfied either way.TV lookup is not here. §11 builds movies concretely first, and TV metadata belongs with phase 6.
Verification
just cilocally: fmt, clippy-D warnings,cargo machete, 19 tests passing. 18 of those are the new wiremock-backed tests incrates/arr-meta/tests/tmdb.rs— no live calls, per §12. Fixtures are TMDB-shaped JSON undertests/fixtures/.Covered: empty-string dates normalised to
None, earliest-digital across countries ignoring theatrical and physical entries, theatrical-only and future-dated digital both failing the gate, cache hit and TTL expiry, 401/404/429/500 mapping,Retry-Afterparsing, failures not cached, andDebugnot leaking the API key.@@ -0,0 +85,4 @@params.push(("year", year.to_string()));}let key = match year {The unescaped query creates cache-key collisions:
search_movies("dune&year=2024", None)andsearch_movies("dune", Some(2024))use the same key but send different requests. Build the key from encoded or structured parameters.@@ -0,0 +147,4 @@if status.is_success() {let body: Arc<str> = Arc::from(response.text().await?);self.cache.insert(cache_key.to_owned(), Arc::clone(&body));A successful HTTP response is cached before it is decoded, so malformed JSON makes all retries return
Error::Decodefrom the cache for a day. Cache only after decoding succeeds, or evict on decode failure.2cfde0342cto0fa422e170Reviewed
0fa422e17038adba5caaea2eb3f9e5c3ed2936b3. Previous findings remain applicable.Two ways a cached entry could be wrong. The key was built by string concatenation in parallel with the URL, so `search_movies("dune&year=2024", None)` and `search_movies("dune", Some(2024))` produced the same key for two different requests. Both key and URL now come from one `Url`, percent-encoded identically, and the API key is appended at send time so it cannot reach a key or a log. A successful response was cached before it was decoded, so one malformed body returned `Error::Decode` from the cache for the whole time-to-live. Decoding now happens first and only what decoded is stored.@@ -0,0 +85,4 @@params.push(("year", year.to_string()));}let page: RawSearchPage = self.get_json("search/movie", ¶ms).await?;Fixed in
06d0675(fix(meta): derive the cache key from the encoded URL).The key and the request URL now come from a single
UrlinTmdbClient::request_url, so the key is percent-encoded exactly as the request is —query=dune%26year%3D2024cannot collide withquery=dune&year=2024. The parallelformat!key-building is gone.The API key moved out of that function too: it is appended in
fetchat send time, so it can never reach a cache key or a log line.Two regression tests:
a_query_containing_separators_does_not_collide_with_a_yearasserts two requests reach the server, anda_query_that_spells_out_the_api_key_is_still_just_a_queryasserts an injectedapi_key=in the query stays a query value.@@ -0,0 +147,4 @@////// The API key is deliberately not in the URL yet: it is appended at send/// time so it can never reach a cache key or a log line.fn request_url(&self, path: &str, params: &[(&str, String)]) -> Result<(Url, String)> {Fixed in the same commit.
get_jsonnow decodes before it inserts, so only a body that parsed is stored. On the cache-hit path a decode failure evicts the entry rather than serving it again — that branch should be unreachable, since nothing enters the cache undecoded, but it beats a stuck error.malformed_json_is_a_decode_error_and_is_not_cachedcovers it: a garbage body returnsError::Decode, then a good response on the retry reaches the server and parses.Reviewed
06d0675585cd0c08e4888bb04945ee7dc6ce49a4. No new findings.@@ -0,0 +85,4 @@params.push(("year", year.to_string()));}let page: RawSearchPage = self.get_json("search/movie", ¶ms).await?;Verified: deriving the key from the encoded URL removes the collision.
@@ -0,0 +147,4 @@////// The API key is deliberately not in the URL yet: it is appended at send/// time so it can never reach a cache key or a log line.fn request_url(&self, path: &str, params: &[(&str, String)]) -> Result<(Url, String)> {Verified: decoding before insertion prevents a malformed body from being retained.
Reviewed
738e871c70a44890cd36653e685c592f8b2465f0. No findings.