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
+81 -5
View File
@@ -397,8 +397,8 @@ pub(crate) struct TmdbSearchItem {
#[serde(default)]
pub(crate) genre_ids: Vec<u32>,
/// 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<String>,
}
@@ -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<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
/// 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);
}
}