From a7cb27984b78a815b8d76f75a7411e18036fd912 Mon Sep 17 00:00:00 2001 From: Worph Date: Sun, 13 Sep 2026 15:56:35 +0200 Subject: [PATCH] align with metadata standard --- feeder-plugin/tribler-feeder/src/enrich.rs | 37 +++++----- feeder-plugin/tribler-feeder/src/tmdb.rs | 86 ++++++++++++++++++++-- 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/feeder-plugin/tribler-feeder/src/enrich.rs b/feeder-plugin/tribler-feeder/src/enrich.rs index f6fd1bc..2ac63a1 100644 --- a/feeder-plugin/tribler-feeder/src/enrich.rs +++ b/feeder-plugin/tribler-feeder/src/enrich.rs @@ -13,8 +13,8 @@ use tracing::{debug, warn}; use crate::title::clean_torrent_title; use crate::tmdb::{ - principal_top_n, season_episode_bounds, SeasonEpisodeBounds, TmdbCall, TmdbClient, - TmdbExternalIds, TmdbHit, TmdbKind, TmdbTvDetails, + principal_top_n, season_episode_bounds, tmdb_title_member_keys, SeasonEpisodeBounds, TmdbCall, + TmdbClient, TmdbExternalIds, TmdbHit, TmdbKind, TmdbTvDetails, }; use crate::tmdb_budget::TmdbBudget; use meta_feeder_sdk::cache::MidhashCache; @@ -669,7 +669,7 @@ async fn compute_enrichment_inner( Some(resolved) => resolved, None => return EnrichOutcome::Noop, }; - build_enrichment_patch(record, enricher, hit, matched_kind, raw_title).await + build_enrichment_patch(record, enricher, hit, matched_kind).await } /// Pick the canonical TMDB entry for `record`. Prefers the **anchored** path — @@ -722,36 +722,33 @@ async fn resolve_enrichment_hit( /// content-kind reconciliation (movie clears bogus season/episode; tv normalises /// to `episode` and bounds-checks the parsed season/episode — an out-of-bounds /// verdict drops the record), and the transient `posterPath` the poster store -/// consumes. `raw_title` is preserved as `originalTitle`. +/// consumes. The raw release string is never copied into a title key — it +/// stays in `fileName`. async fn build_enrichment_patch( record: &DiscoveryRecord, enricher: &TmdbEnricher, hit: TmdbHit, matched_kind: TmdbKind, - raw_title: Option, ) -> EnrichOutcome { let mut set: BTreeMap = BTreeMap::new(); let mut remove: Vec = Vec::new(); set.insert("title".to_string(), hit.title.clone()); - // Preserve the raw tracker release title (quality tags / release-group - // info) in `originalTitle`. This is the canonical home now that the - // bespoke `releaseTitle` key is retired — reuse-before-invent - // (METADATA_KEYS.md rule #1, §14.10 title sprawl). - if let Some(raw_title) = raw_title { - set.insert("originalTitle".to_string(), raw_title); - } - // TMDB's original-language title (e.g. "ナルト") is a genuine localized - // title — file it under the namespaced `titles/{lang3}` (METADATA_KEYS.md - // §3), keyed by TMDB's `original_language`, instead of clobbering - // `originalTitle`. Skipped when the language is outside the common 639-1 - // set or the title is blank (originalTitle still carries the raw title). + // The raw tracker release string is NOT stored here — it is emitted as + // `fileName` at ingest (METADATA_KEYS.md §1). `originalTitle` carries TMDB's + // *original-language* title (e.g. "ナルト" / "Sousou no Frieren"), clean — + // never the release string. if let Some(orig) = hit.original_title.clone() { if !orig.trim().is_empty() { - if let Some(lang3) = hit.original_lang3() { - set.insert(format!("titles/{lang3}"), orig); - } + set.insert("originalTitle".to_string(), orig); } } + // Whoever writes `title`/`originalTitle` also files them as members of the + // language-nested `titles/{lang3}/{name}` key-set (METADATA_KEYS.md): the + // original under TMDB's `original_language` (`und` when unmapped), the title + // under `eng` when it differs from the original. + for key in tmdb_title_member_keys(&hit) { + set.insert(key, "true".to_string()); + } if let Some(o) = hit.overview.clone() { if !o.trim().is_empty() { // Canonical synopsis field is the language-namespaced diff --git a/feeder-plugin/tribler-feeder/src/tmdb.rs b/feeder-plugin/tribler-feeder/src/tmdb.rs index e7d54cc..3cafe5b 100644 --- a/feeder-plugin/tribler-feeder/src/tmdb.rs +++ b/feeder-plugin/tribler-feeder/src/tmdb.rs @@ -397,8 +397,8 @@ pub(crate) struct TmdbSearchItem { #[serde(default)] pub(crate) genre_ids: Vec, /// ISO 639-1 (2-letter) language the title was originally produced in. - /// Mapped to `lang3` and used to file `original_title` under - /// `titles/{lang3}` (METADATA_KEYS.md §3). + /// Mapped to `lang3` and used to file `original_title` as a + /// `titles/{lang3}/{name}` member (METADATA_KEYS.md §3). #[serde(default)] pub(crate) original_language: Option, } @@ -438,8 +438,8 @@ impl TmdbSearchItem { /// Map an ISO 639-1 (2-letter) code to its ISO 639-3 (`lang3`) equivalent, /// matching the store's convention (`eng`, `jpn`, `fra`; the 639-2/T variant /// where B/T differ, e.g. `deu` not `ger`). Covers the languages TMDB -/// commonly returns; unknown codes return `None` so the caller skips the -/// `titles/{lang3}` write rather than persisting a non-`lang3` key. +/// commonly returns; unknown codes return `None` so the caller files the name under `und` +/// rather than persisting a non-`lang3` key. pub(crate) fn iso639_1_to_3(code: &str) -> Option<&'static str> { Some(match code.to_ascii_lowercase().as_str() { "en" => "eng", @@ -504,12 +504,53 @@ pub(crate) struct TmdbHit { impl TmdbHit { /// TMDB `original_language` (ISO 639-1) mapped to the store's `lang3` /// (ISO 639-3), or `None` when the language is outside the common set or - /// absent. Used to file `original_title` under `titles/{lang3}` (§3). + /// absent. Used to file `original_title` under `titles/{lang3}/{name}` (§3). pub(crate) fn original_lang3(&self) -> Option<&'static str> { iso639_1_to_3(self.original_language.as_deref()?.trim()) } } +/// The language TMDB's `name`/`title` is written in. Every request goes out +/// without a `language` param, so TMDB answers in its default, en-US. +pub(crate) const METADATA_LANG3: &str = "eng"; + +/// The `titles/{lang3}/{name}` key-set member for `name` (METADATA_KEYS.md), or +/// `None` when it normalises to empty: trimmed, whitespace runs collapsed, +/// case/diacritics/`%` verbatim, and `/` (the key-set separator) written as +/// U+2215 `∕`. +/// +/// CROSS-BINARY CONTRACT: the indexer feeder's `tmdb.rs::title_member_key` and +/// the card feeder's `tmdb_client.rs::title_member_key` write the same shape; +/// two peers must produce the same key for one name. +pub(crate) fn title_member_key(lang3: &str, name: &str) -> Option { + let name = name.split_whitespace().collect::>().join(" "); + if name.is_empty() { + return None; + } + Some(format!("titles/{lang3}/{}", name.replace('/', "\u{2215}"))) +} + +/// The `titles/{lang3}/{name}` members this feeder files from a TMDB hit: its +/// title and original title (a search hit carries no AKAs). The original goes +/// under its `original_language` (`und` when unmapped); the title under +/// [`METADATA_LANG3`] **unless it equals the original** — TMDB falls back to +/// the original name when it has no en-US translation, so `3%` is +/// `titles/por/3%` only, not also an English name. Same rule as the indexer +/// feeder's `tmdb_title_member_keys`. +pub(crate) fn tmdb_title_member_keys(hit: &TmdbHit) -> Vec { + let norm = |s: &str| s.split_whitespace().collect::>().join(" "); + let mut keys = Vec::new(); + let original = hit.original_title.as_deref().map(norm).filter(|o| !o.is_empty()); + if let Some(o) = &original { + keys.extend(title_member_key(hit.original_lang3().unwrap_or("und"), o)); + } + let title = norm(&hit.title); + if title != "(untitled)" && original.as_deref() != Some(title.as_str()) { + keys.extend(title_member_key(METADATA_LANG3, &title)); + } + keys +} + /// Authoritative TV structure from `GET /3/tv/{id}`, used to bounds-check /// a title-parsed season/episode. Only the two structural fields are /// decoded; everything else in the (large) details payload is ignored. @@ -849,3 +890,38 @@ mod season_bounds_tests { assert_eq!(season_episode_bounds(&d, Some(-1), None), SeasonEpisodeBounds::Contradiction); } } + +#[cfg(test)] +mod title_member_tests { + use super::*; + + fn hit(title: &str, orig: Option<&str>, lang: Option<&str>) -> TmdbHit { + TmdbHit { + tmdbid: 1, + title: title.to_string(), + original_title: orig.map(str::to_string), + original_language: lang.map(str::to_string), + overview: None, + year: None, + poster_path: None, + genre_ids: Vec::new(), + } + } + + #[test] + fn title_and_original_are_filed_as_language_nested_members() { + assert_eq!( + tmdb_title_member_keys(&hit("Naruto", Some("ナルト"), Some("ja"))), + vec!["titles/jpn/ナルト", "titles/eng/Naruto"] + ); + // Title == original (no en-US translation) is filed once. + assert_eq!(tmdb_title_member_keys(&hit("3%", Some("3%"), Some("pt"))), vec!["titles/por/3%"]); + // Unmapped language → und; `/` → U+2215; TMDB's placeholder is not a name. + assert_eq!( + tmdb_title_member_keys(&hit("(untitled)", Some("Fate/Zero"), Some("xx"))), + vec!["titles/und/Fate\u{2215}Zero"] + ); + assert_eq!(title_member_key("eng", " 3 Percent "), Some("titles/eng/3 Percent".to_string())); + assert_eq!(title_member_key("eng", " "), None); + } +}