fix(tmdb): fall back to original language for missing trailers too (#1136)

* fix(tmdb): fall back to original language for missing trailers too

TMDB language-filters videos the same way it filters overviews: a
Russian-only title has its trailer tagged iso_639_1=ru, so an en-US
request returns no trailer — the trailer only appeared when the user
switched the app language. The original-language fallback previously
triggered on an empty overview only, so titles that had a translated
overview but a language-only trailer never got one.

detailsFallbackLanguage now also triggers when the payload has no usable
YouTube trailer, and fillDetailsFromFallback fills the overview and the
trailer independently — a present app-language overview is kept while the
trailer is pulled from the original-language payload. The fallback fetch
stays best-effort and cached per language.

Tests: extended tmdb-language-fallback.spec.ts (trailer-missing trigger,
independent overview/trailer fill, YouTube-only guard). Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(tmdb): cover combined overview+trailer fallback fill

Adds the missing case where the primary payload lacks both overview and
trailer and the fallback supplies both — the two fill branches are
independent (greptile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-06 19:33:12 +02:00
1 parent df01b738c0
commit 64839e3495
4 files changed
+110 -28

No files matched your search

@@ -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
@@ -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()
@@ -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', () => {
@@ -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;
}
/**