diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index eabdb5d27..cac28e6ba 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -139,13 +139,15 @@ The `language` param derives from the app language setting (`Language` enum → TMDB code, e.g. `de` → `de-DE`); cache rows are keyed per language, so switching the app language re-fetches localized metadata. -TMDB does NOT fall back on missing translations — a Russian-only series -returns empty overviews for `en-US`. When the app-language payload has no -overview, the enrichment refetches once in the content's -`original_language` and fills only the missing text -(`tmdb-language-fallback.ts`): the details overview, and — via the same -rule in `TmdbSeasonService` when a season payload carries no usable text — -the season overview and per-episode names/overviews. Genres, credits and +TMDB language-filters both text AND videos — a Russian-only title returns +an empty overview and no trailer for `en-US` (its trailer is tagged +`iso_639_1=ru`). When the app-language payload is missing either, the +enrichment refetches once in the content's `original_language` and fills +only the missing fields (`tmdb-language-fallback.ts`): the details +overview and/or trailer (each independently, so a present app-language +overview is kept while the trailer is filled), and — via the same rule in +`TmdbSeasonService` when a season payload carries no usable text — the +season overview and per-episode names/overviews. Genres, credits and artwork stay in the app language; both language rows land in the cache. Trailers embed via `https://www.youtube-nocookie.com/embed/…`. YouTube diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index 0ce8339c6..bfed5f41b 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -220,9 +220,9 @@ export class TmdbEnrichmentService { return details; } - // TMDB does not fall back on missing translations: a Russian-only - // title has an empty overview in en-US. Retry once in the content's - // original language and fill only the missing text. + // TMDB language-filters both text and videos: a Russian-only title + // has an empty overview AND no trailer in en-US. Retry once in the + // content's original language and fill only the missing fields. const fallbackLanguage = detailsFallbackLanguage( details, this.runtime.language() diff --git a/libs/services/src/lib/tmdb/tmdb-language-fallback.spec.ts b/libs/services/src/lib/tmdb/tmdb-language-fallback.spec.ts index 0d7e8731d..ed7690380 100644 --- a/libs/services/src/lib/tmdb/tmdb-language-fallback.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-language-fallback.spec.ts @@ -6,22 +6,35 @@ import { } from './tmdb-language-fallback'; import { TmdbSeasonDetails } from './tmdb.types'; +const trailer = { site: 'YouTube', key: 'abc123', type: 'Trailer' }; +const videos = { results: [trailer] }; + describe('detailsFallbackLanguage', () => { it('returns the original language when the overview is empty', () => { expect( detailsFallbackLanguage( - { id: 1, overview: '', original_language: 'ru' }, + { id: 1, overview: '', original_language: 'ru', videos }, 'en-US' ) ).toBe('ru'); }); - it('returns null when the overview is present', () => { + it('returns the original language when the trailer is missing', () => { + // Overview present, but TMDB filtered out the Russian-only trailer expect( detailsFallbackLanguage( { id: 1, overview: 'Plot', original_language: 'ru' }, 'en-US' ) + ).toBe('ru'); + }); + + it('returns null when both overview and trailer are present', () => { + expect( + detailsFallbackLanguage( + { id: 1, overview: 'Plot', original_language: 'ru', videos }, + 'en-US' + ) ).toBeNull(); }); @@ -42,8 +55,8 @@ describe('detailsFallbackLanguage', () => { describe('fillDetailsFromFallback', () => { it('fills only the missing overview', () => { const merged = fillDetailsFromFallback( - { id: 1, overview: '', original_language: 'ru' }, - { id: 1, overview: 'Русское описание' } + { id: 1, overview: '', original_language: 'ru', videos }, + { id: 1, overview: 'Русское описание', videos } ); expect(merged.overview).toBe('Русское описание'); expect(merged.original_language).toBe('ru'); @@ -51,11 +64,52 @@ describe('fillDetailsFromFallback', () => { it('keeps an existing overview', () => { const merged = fillDetailsFromFallback( - { id: 1, overview: 'English plot' }, - { id: 1, overview: 'Русское описание' } + { id: 1, overview: 'English plot', videos }, + { id: 1, overview: 'Русское описание', videos } ); expect(merged.overview).toBe('English plot'); }); + + it('fills both overview and trailer when the primary payload has neither', () => { + const merged = fillDetailsFromFallback( + { id: 1, overview: '', original_language: 'ru' }, + { id: 1, overview: 'Русское описание', videos } + ); + expect(merged.overview).toBe('Русское описание'); + expect(merged.videos?.results?.[0].key).toBe('abc123'); + }); + + it('fills the trailer when the primary payload has none', () => { + const merged = fillDetailsFromFallback( + { id: 1, overview: 'English plot' }, + { id: 1, overview: 'Русское описание', videos } + ); + // overview kept (present), trailer pulled from the fallback + expect(merged.overview).toBe('English plot'); + expect(merged.videos?.results?.[0].key).toBe('abc123'); + }); + + it('keeps an existing trailer', () => { + const own = { + results: [{ site: 'YouTube', key: 'own', type: 'Trailer' }], + }; + const merged = fillDetailsFromFallback( + { id: 1, overview: 'Plot', videos: own }, + { id: 1, overview: 'x', videos } + ); + expect(merged.videos?.results?.[0].key).toBe('own'); + }); + + it('does not treat a non-YouTube video as a usable trailer', () => { + const vimeo = { + results: [{ site: 'Vimeo', key: 'v', type: 'Trailer' }], + }; + const merged = fillDetailsFromFallback( + { id: 1, overview: 'Plot', videos: vimeo }, + { id: 1, overview: 'x', videos } + ); + expect(merged.videos?.results?.[0].key).toBe('abc123'); + }); }); describe('season fallback', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-language-fallback.ts b/libs/services/src/lib/tmdb/tmdb-language-fallback.ts index 3623d9ee9..90767ead3 100644 --- a/libs/services/src/lib/tmdb/tmdb-language-fallback.ts +++ b/libs/services/src/lib/tmdb/tmdb-language-fallback.ts @@ -1,27 +1,43 @@ import { TmdbDetails, TmdbEpisode, TmdbSeasonDetails } from './tmdb.types'; /** - * TMDB text fields (overview, episode overviews) are NOT auto-translated: - * a Russian-only series returns empty overviews for `en-US`. When the - * app-language payload has no text, we refetch once in the content's - * `original_language` and fill only the missing text fields — everything - * else (genres, credits, artwork) stays in the app language. + * TMDB filters BOTH text (overview, episode overviews) AND videos by the + * `language` param: a Russian-only title returns an empty overview and no + * trailer for `en-US`, because its trailer is tagged `iso_639_1=ru`. When + * the app-language payload is missing text or a trailer, we refetch once + * in the content's `original_language` and fill only the missing fields — + * everything else (genres, credits, artwork) stays in the app language. */ function hasText(value: string | null | undefined): value is string { return Boolean(value?.trim()); } +/** True when the payload carries a usable YouTube trailer/teaser */ +function hasYoutubeTrailer(details: TmdbDetails | null): boolean { + return (details?.videos?.results ?? []).some( + (video) => + video.site === 'YouTube' && + Boolean(video.key) && + (video.type === 'Trailer' || video.type === 'Teaser') + ); +} + /** * The original language to retry with, or null when the primary payload - * already has an overview / is in that language anyway. + * already has both an overview and a trailer / is in that language anyway. */ export function detailsFallbackLanguage( details: TmdbDetails | null, currentLanguage: string ): string | null { const original = details?.original_language?.trim(); - if (!original || hasText(details?.overview)) { + if (!original) { + return null; + } + // Trailers, like overviews, are language-filtered by TMDB — retry when + // either is missing in the app language. + if (hasText(details?.overview) && hasYoutubeTrailer(details)) { return null; } return currentLanguage.toLowerCase().startsWith(original.toLowerCase()) @@ -29,17 +45,27 @@ export function detailsFallbackLanguage( : original; } -/** Fill the primary payload's empty overview from the fallback payload */ +/** + * Fill the primary payload's missing overview and/or trailer from the + * fallback (original-language) payload — each field independently, so a + * present app-language overview is kept even while the trailer is filled. + */ export function fillDetailsFromFallback( primary: TmdbDetails, fallback: TmdbDetails | null ): TmdbDetails { - if (!fallback || hasText(primary.overview)) { + if (!fallback) { return primary; } - return hasText(fallback.overview) - ? { ...primary, overview: fallback.overview } - : primary; + + let result = primary; + if (!hasText(primary.overview) && hasText(fallback.overview)) { + result = { ...result, overview: fallback.overview }; + } + if (!hasYoutubeTrailer(primary) && hasYoutubeTrailer(fallback)) { + result = { ...result, videos: fallback.videos }; + } + return result; } /**