align with metadata standard
This commit is contained in:
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user