align with metadata standard
Publish image / publish (push) Failing after 13s

This commit is contained in:
Worph
2026-09-13 15:56:35 +02:00
parent 48b0e52146
commit a7cb27984b
2 changed files with 98 additions and 25 deletions
+16 -19
View File
@@ -13,8 +13,8 @@ use tracing::{debug, warn};
use crate::title::clean_torrent_title; use crate::title::clean_torrent_title;
use crate::tmdb::{ use crate::tmdb::{
principal_top_n, season_episode_bounds, SeasonEpisodeBounds, TmdbCall, TmdbClient, principal_top_n, season_episode_bounds, tmdb_title_member_keys, SeasonEpisodeBounds, TmdbCall,
TmdbExternalIds, TmdbHit, TmdbKind, TmdbTvDetails, TmdbClient, TmdbExternalIds, TmdbHit, TmdbKind, TmdbTvDetails,
}; };
use crate::tmdb_budget::TmdbBudget; use crate::tmdb_budget::TmdbBudget;
use meta_feeder_sdk::cache::MidhashCache; use meta_feeder_sdk::cache::MidhashCache;
@@ -669,7 +669,7 @@ async fn compute_enrichment_inner(
Some(resolved) => resolved, Some(resolved) => resolved,
None => return EnrichOutcome::Noop, 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 — /// Pick the canonical TMDB entry for `record`. Prefers the **anchored** path —
@@ -722,35 +722,32 @@ async fn resolve_enrichment_hit(
/// content-kind reconciliation (movie clears bogus season/episode; tv normalises /// content-kind reconciliation (movie clears bogus season/episode; tv normalises
/// to `episode` and bounds-checks the parsed season/episode — an out-of-bounds /// to `episode` and bounds-checks the parsed season/episode — an out-of-bounds
/// verdict drops the record), and the transient `posterPath` the poster store /// 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( async fn build_enrichment_patch(
record: &DiscoveryRecord, record: &DiscoveryRecord,
enricher: &TmdbEnricher, enricher: &TmdbEnricher,
hit: TmdbHit, hit: TmdbHit,
matched_kind: TmdbKind, matched_kind: TmdbKind,
raw_title: Option<String>,
) -> EnrichOutcome { ) -> EnrichOutcome {
let mut set: BTreeMap<String, String> = BTreeMap::new(); let mut set: BTreeMap<String, String> = BTreeMap::new();
let mut remove: Vec<String> = Vec::new(); let mut remove: Vec<String> = Vec::new();
set.insert("title".to_string(), hit.title.clone()); set.insert("title".to_string(), hit.title.clone());
// Preserve the raw tracker release title (quality tags / release-group // The raw tracker release string is NOT stored here — it is emitted as
// info) in `originalTitle`. This is the canonical home now that the // `fileName` at ingest (METADATA_KEYS.md §1). `originalTitle` carries TMDB's
// bespoke `releaseTitle` key is retired — reuse-before-invent // *original-language* title (e.g. "ナルト" / "Sousou no Frieren"), clean —
// (METADATA_KEYS.md rule #1, §14.10 title sprawl). // never the release string.
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).
if let Some(orig) = hit.original_title.clone() { if let Some(orig) = hit.original_title.clone() {
if !orig.trim().is_empty() { if !orig.trim().is_empty() {
if let Some(lang3) = hit.original_lang3() { set.insert("originalTitle".to_string(), orig);
set.insert(format!("titles/{lang3}"), 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 let Some(o) = hit.overview.clone() {
if !o.trim().is_empty() { if !o.trim().is_empty() {
+81 -5
View File
@@ -397,8 +397,8 @@ pub(crate) struct TmdbSearchItem {
#[serde(default)] #[serde(default)]
pub(crate) genre_ids: Vec<u32>, pub(crate) genre_ids: Vec<u32>,
/// ISO 639-1 (2-letter) language the title was originally produced in. /// ISO 639-1 (2-letter) language the title was originally produced in.
/// Mapped to `lang3` and used to file `original_title` under /// Mapped to `lang3` and used to file `original_title` as a
/// `titles/{lang3}` (METADATA_KEYS.md §3). /// `titles/{lang3}/{name}` member (METADATA_KEYS.md §3).
#[serde(default)] #[serde(default)]
pub(crate) original_language: Option<String>, pub(crate) original_language: Option<String>,
} }
@@ -438,8 +438,8 @@ impl TmdbSearchItem {
/// Map an ISO 639-1 (2-letter) code to its ISO 639-3 (`lang3`) equivalent, /// 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 /// 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 /// where B/T differ, e.g. `deu` not `ger`). Covers the languages TMDB
/// commonly returns; unknown codes return `None` so the caller skips the /// commonly returns; unknown codes return `None` so the caller files the name under `und`
/// `titles/{lang3}` write rather than persisting a non-`lang3` key. /// rather than persisting a non-`lang3` key.
pub(crate) fn iso639_1_to_3(code: &str) -> Option<&'static str> { pub(crate) fn iso639_1_to_3(code: &str) -> Option<&'static str> {
Some(match code.to_ascii_lowercase().as_str() { Some(match code.to_ascii_lowercase().as_str() {
"en" => "eng", "en" => "eng",
@@ -504,12 +504,53 @@ pub(crate) struct TmdbHit {
impl TmdbHit { impl TmdbHit {
/// TMDB `original_language` (ISO 639-1) mapped to the store's `lang3` /// 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 /// (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> { pub(crate) fn original_lang3(&self) -> Option<&'static str> {
iso639_1_to_3(self.original_language.as_deref()?.trim()) 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<String> {
let name = name.split_whitespace().collect::<Vec<_>>().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<String> {
let norm = |s: &str| s.split_whitespace().collect::<Vec<_>>().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 /// Authoritative TV structure from `GET /3/tv/{id}`, used to bounds-check
/// a title-parsed season/episode. Only the two structural fields are /// a title-parsed season/episode. Only the two structural fields are
/// decoded; everything else in the (large) details payload is ignored. /// 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); 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);
}
}