feat(meta): TMDB search and movie lookup #49
Reference in New Issue
Block a user
Delete Branch "issue/14-tmdb"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.