diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts index e42a5626d..3f864d44f 100644 --- a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts @@ -247,6 +247,55 @@ describe('request header overrides', () => { expect(mockOnBeforeSendHeaders).not.toHaveBeenCalled(); }); + it('injects the YouTube embed Referer when none is present', async () => { + const { registerStaticHeaderShims } = await import( + './request-header-overrides.service' + ); + + registerStaticHeaderShims(); + expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://www.youtube-nocookie.com/embed/abc123' + ); + + expect(headers['Referer']).toBe('https://4gray.github.io/iptvnator/'); + }); + + it('keeps an existing Referer on YouTube embed requests', async () => { + const { registerStaticHeaderShims } = await import( + './request-header-overrides.service' + ); + + registerStaticHeaderShims(); + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://www.youtube-nocookie.com/embed/abc123', + { referer: 'http://localhost:4200/' } + ); + + expect(headers['referer']).toBe('http://localhost:4200/'); + expect(headers['Referer']).toBeUndefined(); + }); + + it('does not inject the embed Referer for non-YouTube hosts', async () => { + const { registerStaticHeaderShims } = await import( + './request-header-overrides.service' + ); + + registerStaticHeaderShims(); + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts' + ); + + expect(headers['Referer']).toBeUndefined(); + }); + it('replaces existing header names case-insensitively', async () => { const { configureRequestHeaderOverride } = await import('./request-header-overrides.service'); diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.ts index 5a00a4a6b..6c4024a6e 100644 --- a/apps/electron-backend/src/app/services/request-header-overrides.service.ts +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.ts @@ -11,6 +11,19 @@ const headerOverrideUrlFilter = { urls: ['http://*/*', 'https://*/*'], }; +/** + * YouTube refuses to configure the embedded player when the /embed request + * carries no Referer ("Error 153 — Video player configuration error"). + * The packaged app loads the renderer from file://, which never sends a + * Referer, so trailer iframes break in production while working in dev + * (localhost origin). Injecting the project site as Referer restores them. + */ +const YOUTUBE_EMBED_HOSTS = new Set([ + 'www.youtube-nocookie.com', + 'www.youtube.com', +]); +const YOUTUBE_EMBED_REFERER = 'https://4gray.github.io/iptvnator/'; + let activeHeaderOverride: HeaderOverride | null = null; let activeScopedHeaderOverride: HeaderOverride | null = null; let listenerRegistered = false; @@ -60,11 +73,34 @@ function setRequestHeader( requestHeaders[headerName] = headerValue; } +function applyYoutubeEmbedRefererShim( + url: string, + requestHeaders: Record +): void { + let host: string; + try { + host = new URL(url).hostname; + } catch { + return; + } + if (!YOUTUBE_EMBED_HOSTS.has(host)) { + return; + } + + const hasReferer = Object.keys(requestHeaders).some( + (name) => name.toLowerCase() === 'referer' + ); + if (!hasReferer) { + requestHeaders['Referer'] = YOUTUBE_EMBED_REFERER; + } +} + function handleBeforeSendHeaders( details: Electron.OnBeforeSendHeadersListenerDetails, callback: (beforeSendResponse: Electron.BeforeSendResponse) => void ): void { const requestHeaders = { ...details.requestHeaders }; + applyYoutubeEmbedRefererShim(details.url, requestHeaders); const overrides = [activeHeaderOverride, activeScopedHeaderOverride].filter( (override): override is HeaderOverride => Boolean(override && shouldApplyOverride(details.url, override)) @@ -144,6 +180,14 @@ export function configureRequestHeaderOverride( ensureHeaderOverrideListener(); } +/** + * Registers the header listener at startup so the YouTube embed Referer + * shim is active even before any playlist-level override is configured. + */ +export function registerStaticHeaderShims(): void { + ensureHeaderOverrideListener(); +} + export function clearRequestHeaderOverride(): void { activeHeaderOverride = null; activeScopedHeaderOverride = null; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 09094ee58..84f772e1c 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -25,6 +25,7 @@ import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; import StalkerEvents from './app/events/stalker.events'; import { isStartupTraceEnabled, trace } from './app/services/debug-trace'; +import { registerStaticHeaderShims } from './app/services/request-header-overrides.service'; import { AppUpdateService } from './app/services/app-update.service'; import { databaseWorkerClient } from './app/services/database-worker-client'; import WindowEvents from './app/events/window.events'; @@ -110,6 +111,7 @@ export default class Main { }); AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService); + registerStaticHeaderShims(); ElectronEvents.bootstrapElectronEvents(); WindowEvents.bootstrapWindowEvents(); EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index f87fad7f9..1960d0cad 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -130,6 +130,23 @@ 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 +artwork stay in the app language; both language rows land in the cache. + +Trailers embed via `https://www.youtube-nocookie.com/embed/…`. YouTube +requires a Referer on the embed request ("Error 153 — Video player +configuration error" without one); the packaged Electron app loads from +`file://`, which never sends one, so the Electron main process injects the +project site as Referer for YouTube embed hosts +(`request-header-overrides.service.ts`, registered at startup via +`registerStaticHeaderShims`). Dev builds (localhost origin) are unaffected. + ## Season/Episode Enrichment Show-level merges store the matched id as `tmdb_id` on the enriched info diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index 5558424b7..b9dbf5cf7 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -16,6 +16,10 @@ import { parseProviderTmdbId, pickConfidentMatch, } from './tmdb-matcher'; +import { + detailsFallbackLanguage, + fillDetailsFromFallback, +} from './tmdb-language-fallback'; import { TmdbPersonService } from './tmdb-person.service'; import { TmdbRuntimeService } from './tmdb-runtime.service'; import { TmdbSeasonService } from './tmdb-season.service'; @@ -199,7 +203,38 @@ export class TmdbEnrichmentService { mediaType: TmdbMediaType, tmdbId: number ): Promise { - const language = this.runtime.language(); + const details = await this.fetchDetails( + mediaType, + tmdbId, + this.runtime.language() + ); + if (!details) { + 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. + const fallbackLanguage = detailsFallbackLanguage( + details, + this.runtime.language() + ); + if (!fallbackLanguage) { + return details; + } + const fallback = await this.fetchDetails( + mediaType, + tmdbId, + fallbackLanguage + ); + return fillDetailsFromFallback(details, fallback); + } + + private async fetchDetails( + mediaType: TmdbMediaType, + tmdbId: number, + language: string + ): Promise { const lookupKey = buildDetailsLookupKey(tmdbId); const cached = await this.cache.get(mediaType, lookupKey, 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 new file mode 100644 index 000000000..06921f3ee --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-language-fallback.spec.ts @@ -0,0 +1,103 @@ +import { + detailsFallbackLanguage, + fillDetailsFromFallback, + fillSeasonFromFallback, + seasonNeedsTextFallback, +} from './tmdb-language-fallback'; +import { TmdbSeasonDetails } from './tmdb.types'; + +describe('detailsFallbackLanguage', () => { + it('returns the original language when the overview is empty', () => { + expect( + detailsFallbackLanguage( + { id: 1, overview: '', original_language: 'ru' }, + 'en-US' + ) + ).toBe('ru'); + }); + + it('returns null when the overview is present', () => { + expect( + detailsFallbackLanguage( + { id: 1, overview: 'Plot', original_language: 'ru' }, + 'en-US' + ) + ).toBeNull(); + }); + + it('returns null when already fetching in the original language', () => { + expect( + detailsFallbackLanguage( + { id: 1, overview: '', original_language: 'ru' }, + 'ru-RU' + ) + ).toBeNull(); + }); + + it('returns null without an original language', () => { + expect(detailsFallbackLanguage({ id: 1 }, 'en-US')).toBeNull(); + }); +}); + +describe('fillDetailsFromFallback', () => { + it('fills only the missing overview', () => { + const merged = fillDetailsFromFallback( + { id: 1, overview: '', original_language: 'ru' }, + { id: 1, overview: 'Русское описание' } + ); + expect(merged.overview).toBe('Русское описание'); + expect(merged.original_language).toBe('ru'); + }); + + it('keeps an existing overview', () => { + const merged = fillDetailsFromFallback( + { id: 1, overview: 'English plot' }, + { id: 1, overview: 'Русское описание' } + ); + expect(merged.overview).toBe('English plot'); + }); +}); + +describe('season fallback', () => { + const emptySeason: TmdbSeasonDetails = { + season_number: 1, + overview: '', + episodes: [ + { episode_number: 1, name: 'Episode 1', overview: '' }, + { episode_number: 2, name: '', overview: ' ' }, + ], + }; + + const russianSeason: TmdbSeasonDetails = { + season_number: 1, + overview: 'Описание сезона', + episodes: [ + { episode_number: 1, name: 'Эпизод 1', overview: 'Текст 1' }, + { episode_number: 2, name: 'Эпизод 2', overview: 'Текст 2' }, + ], + }; + + it('detects seasons without any usable text', () => { + expect(seasonNeedsTextFallback(emptySeason)).toBe(true); + expect(seasonNeedsTextFallback(russianSeason)).toBe(false); + expect( + seasonNeedsTextFallback({ + overview: '', + episodes: [{ episode_number: 1, overview: 'Text' }], + }) + ).toBe(false); + }); + + it('fills missing season overview and episode texts by number', () => { + const merged = fillSeasonFromFallback(emptySeason, russianSeason); + expect(merged.overview).toBe('Описание сезона'); + expect(merged.episodes?.[0].overview).toBe('Текст 1'); + // Existing (non-empty) names stay in the app language + expect(merged.episodes?.[0].name).toBe('Episode 1'); + expect(merged.episodes?.[1].name).toBe('Эпизод 2'); + }); + + it('is a no-op without a fallback payload', () => { + expect(fillSeasonFromFallback(emptySeason, null)).toBe(emptySeason); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-language-fallback.ts b/libs/services/src/lib/tmdb/tmdb-language-fallback.ts new file mode 100644 index 000000000..8f027a5af --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-language-fallback.ts @@ -0,0 +1,95 @@ +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. + */ + +function hasText(value: string | null | undefined): value is string { + return Boolean(value?.trim()); +} + +/** + * The original language to retry with, or null when the primary payload + * already has an overview / 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)) { + return null; + } + return currentLanguage.toLowerCase().startsWith(original.toLowerCase()) + ? null + : original; +} + +/** Fill the primary payload's empty overview from the fallback payload */ +export function fillDetailsFromFallback( + primary: TmdbDetails, + fallback: TmdbDetails | null +): TmdbDetails { + if (!fallback || hasText(primary.overview)) { + return primary; + } + return hasText(fallback.overview) + ? { ...primary, overview: fallback.overview } + : primary; +} + +/** True when the season payload carries no usable text at all */ +export function seasonNeedsTextFallback(season: TmdbSeasonDetails): boolean { + if (hasText(season.overview)) { + return false; + } + return !(season.episodes ?? []).some((episode) => + hasText(episode.overview) + ); +} + +/** + * Fill the season overview and per-episode names/overviews that are empty + * in the primary (app-language) payload from the fallback payload. + */ +export function fillSeasonFromFallback( + primary: TmdbSeasonDetails, + fallback: TmdbSeasonDetails | null +): TmdbSeasonDetails { + if (!fallback) { + return primary; + } + + const fallbackByNumber = new Map( + (fallback.episodes ?? []).map((episode) => [ + episode.episode_number, + episode, + ]) + ); + + const episodes = (primary.episodes ?? []).map((episode) => { + const source = fallbackByNumber.get(episode.episode_number); + if (!source) { + return episode; + } + return { + ...episode, + name: hasText(episode.name) ? episode.name : source.name, + overview: hasText(episode.overview) + ? episode.overview + : source.overview, + }; + }); + + return { + ...primary, + overview: hasText(primary.overview) + ? primary.overview + : fallback.overview, + episodes, + }; +} diff --git a/libs/services/src/lib/tmdb/tmdb-season.service.ts b/libs/services/src/lib/tmdb/tmdb-season.service.ts index 9f2f87bf4..a6141ec42 100644 --- a/libs/services/src/lib/tmdb/tmdb-season.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-season.service.ts @@ -2,8 +2,13 @@ import { Injectable, inject } from '@angular/core'; import { TmdbApiService } from './tmdb-api.service'; import { TmdbCacheService } from './tmdb-cache.service'; import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config'; +import { + fillSeasonFromFallback, + seasonNeedsTextFallback, +} from './tmdb-language-fallback'; +import { buildDetailsLookupKey } from './tmdb-matcher'; import { TmdbRuntimeService } from './tmdb-runtime.service'; -import { TmdbEpisode, TmdbSeasonDetails } from './tmdb.types'; +import { TmdbDetails, TmdbEpisode, TmdbSeasonDetails } from './tmdb.types'; /** * Season payloads (overview + episode list with names, overviews, stills, @@ -34,8 +39,64 @@ export class TmdbSeasonService { return null; } + const season = await this.fetchSeason( + tmdbId, + seasonNumber, + this.runtime.language() + ); + if (!season || !seasonNeedsTextFallback(season)) { + return season; + } + + // No usable text in the app language — retry once in the show's + // original language (read from the already-cached show details) + // and fill the missing overviews/names. + const fallbackLanguage = await this.originalShowLanguage(tmdbId); + if ( + !fallbackLanguage || + this.runtime + .language() + .toLowerCase() + .startsWith(fallbackLanguage.toLowerCase()) + ) { + return season; + } + const fallback = await this.fetchSeason( + tmdbId, + seasonNumber, + fallbackLanguage + ); + return fillSeasonFromFallback(season, fallback); + } + + /** + * The show's original_language from the cached TV details row. Season + * fetches always happen after a show-level match, so the row exists; + * null when it doesn't (then no fallback is attempted). + */ + private async originalShowLanguage(tmdbId: number): Promise { + try { + const cached = await this.cache.get( + 'tv', + buildDetailsLookupKey(tmdbId), + this.runtime.language() + ); + if (!cached?.payload) { + return null; + } + const details = JSON.parse(cached.payload) as TmdbDetails; + return details.original_language?.trim() || null; + } catch { + return null; + } + } + + private async fetchSeason( + tmdbId: number, + seasonNumber: number, + language: string + ): Promise { try { - const language = this.runtime.language(); const lookupKey = `id:${tmdbId}|season:${seasonNumber}`; const cached = await this.cache.get('tv', lookupKey, language); diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index ead6973ec..7fefae1f5 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -59,6 +59,8 @@ export interface TmdbVideo { interface TmdbDetailsBase { id: number; overview?: string; + /** ISO 639-1 code of the content's original language ("ru") */ + original_language?: string; genres?: TmdbGenre[]; vote_average?: number; vote_count?: number;