mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(tmdb): restore trailers in packaged builds, fall back to original language for missing text
Two production-only gaps reported against the packaged 0.22 build: 1. Trailers showed YouTube "Error 153 — Video player configuration error". YouTube requires a Referer on /embed requests; the packaged app loads the renderer from file://, which never sends one (dev works because the origin is localhost). The Electron main process now injects the project site as Referer for YouTube embed hosts when the header is absent (request-header-overrides.service.ts, registered at startup via registerStaticHeaderShims). Existing Referers are never overwritten, other hosts untouched. 2. Descriptions vanished for content whose original language differs from the app language: TMDB does not fall back on missing translations, so a Russian-only series has empty overviews in en-US. When the app-language payload carries no text, the enrichment refetches once in the content's original_language and fills ONLY the missing text fields (tmdb-language-fallback.ts): details overview, season overview and per-episode names/overviews. Genres, credits and artwork stay in the app language; both language rows share the cache. Tests: 3 new shim cases in request-header-overrides.service.spec.ts, new tmdb-language-fallback.spec.ts (9 cases). Docs updated (tmdb-metadata-enrichment.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce24e16e32
commit
28177c391b
9 files changed
+411
-3
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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<string, string>
|
||||
): 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TmdbDetails | null> {
|
||||
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<TmdbDetails | null> {
|
||||
const lookupKey = buildDetailsLookupKey(tmdbId);
|
||||
|
||||
const cached = await this.cache.get(mediaType, lookupKey, language);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number, TmdbEpisode>(
|
||||
(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,
|
||||
};
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<TmdbSeasonDetails | null> {
|
||||
try {
|
||||
const language = this.runtime.language();
|
||||
const lookupKey = `id:${tmdbId}|season:${seasonNumber}`;
|
||||
|
||||
const cached = await this.cache.get('tv', lookupKey, language);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user