mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals Adds an opt-in TMDB integration (Settings > Metadata) that enriches detail views with a field-level merge — the provider stays authoritative for stream data, TMDB fills editorial fields when the match is confident. Enrichment: - Movie/series details: plot, cast (avatar chips), director, genres, rating, poster/backdrop, official YouTube trailers - Confidence-gated matching: provider tmdb_id trusted; otherwise normalized-title search with year gate (±1; series accept earlier premieres), season-suffix stripping, Cyrillic search-language override, and language-prefix fallback variants - Lazy season/episode enrichment: real episode names, overviews, stills - "Similar" rail (Xtream): TMDB recommendations matched to the catalog - Actor pages per portal with full filmography, availability filter and an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES worker op over the trigram FTS index Infrastructure: - SQLite cache table tmdb_metadata (details, search verdicts, seasons, persons; per-language, TTL-guarded), in-memory fallback for the PWA - Settings: enable toggle, own-API-key override with a live "check key" button; TMDB attribution in Settings and About - Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via tools/tmdb/inject-tmdb-key.mjs when the secret is configured - normalizeTitle shared between renderer and DB worker - CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked) Fixes and refactors along the way: - fix(stalker): Advanced Search sent bare get_ordered_list requests and skipped the auth handshake when isFullStalkerPortal was missing on the active-playlist meta — full portals answered "Authorization failed." and search looked empty; now mirrors the catalog request shape and routes through makeAuthenticatedRequest with URL-based detection - fix(stalker): TMDB fields survive info re-normalization; detail views prefer the store copy patched by async enrichment over stale snapshots - refactor(xtream): split oversized vod/serial detail components into component-scoped playback services; detail routes re-initialize on route param changes (router reuses them for detail-to-detail nav) - i18n: all new keys translated across the 18 locales Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): provide route params observable to inline collection details, linearize regexes The global-collection inline detail host builds a fake ActivatedRoute for VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params. Since the detail components now read route.params via toSignal() (detail-> detail re-init), the missing observable crashed component construction and the content hero never rendered — broke dashboard-activation, favorites and recent Electron E2E on all platforms. Provide the params observable alongside the snapshot and assert it in the component spec. Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in normalizeTitle now excludes opening delimiters inside the classes, and youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u escapes (greptile note). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP Review follow-ups on PR #1123: the Xtream VOD detail badge renders rating_imdb, but the merge wrote the TMDB score only into `rating`, so a TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the provider left it empty, mirroring the Stalker merge. All trailer iframes are normalized to youtube-nocookie.com, so the extra youtube.com frame-src allowance was dead surface (greptile) — removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema Fixes the confirmed findings from the PR #1123 code review: - Stalker search: setSelectedContentType now runs BEFORE setSelectedItem, so the TMDB enrichment gate in the selection hook no longer sees the content type of the previously open tab (wrong/no enrichment after ITV -> search -> movie). - Title normalization is now two-tier (normalizeTitleKeys): the exact normalized form keeps a trailing year, the base form strips it and remembers the tag. Year stripping is anchored to the end of the title ("2001: A Space Odyssey" keeps its year) and language-prefix stripping is UPPERCASE-only ("It: Chapter Two" is no longer amputated). - All catalog matching (similar rail, actor pages, DB worker DB_MATCH_TITLES) compares exact forms first and only accepts year-stripped matches when the stripped tag is year-compatible (+-1) with the TMDB year — "Blade Runner" (1982) can no longer claim a catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped trailingYear so the renderer can apply the guard to worker matches. - mergedBackdrops tolerates a plain-string backdrop_path; enrichment merge+patch blocks are wrapped in try/catch so a malformed provider payload can no longer become an unhandled rejection. - loadGlobalMatches (both actor routes) guards against actor->actor navigation races — a slow match for the previous person no longer overwrites the current one's results. - tmdb_metadata media_type CHECK widened to ('movie','tv','person') and person rows now use the honest 'person' type (TmdbCacheMediaType). Pre-release dev DBs with the narrow CHECK are rebuilt in place — the table is a pure cache, so the migration is a self-healing drop-and-recreate keyed off sqlite_master. Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression coverage: title-normalization.util.spec.ts, two-tier cases in tmdb-similar.util.spec.ts and title-match.operations.spec.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
import {
|
|
FormArray,
|
|
FormBuilder,
|
|
FormControl,
|
|
Validators,
|
|
} from '@angular/forms';
|
|
import {
|
|
CoverSize,
|
|
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
|
DEFAULT_TMDB_SETTINGS,
|
|
EpgViewMode,
|
|
Language,
|
|
normalizeDashboardRailsSettings,
|
|
normalizeExternalPlayerArguments,
|
|
Settings,
|
|
StartupBehavior,
|
|
StreamFormat,
|
|
Theme,
|
|
VideoPlayer,
|
|
} from '@iptvnator/shared/interfaces';
|
|
|
|
export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/;
|
|
|
|
export function createEpgUrlControl(value = ''): FormControl<string | null> {
|
|
return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]);
|
|
}
|
|
|
|
export function createSettingsForm(
|
|
formBuilder: FormBuilder,
|
|
supportsEpg: boolean
|
|
) {
|
|
return formBuilder.group({
|
|
player: [VideoPlayer.VideoJs],
|
|
...(supportsEpg
|
|
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
|
|
: {}),
|
|
streamFormat: StreamFormat.AutoStreamFormat,
|
|
openStreamOnDoubleClick: false,
|
|
language: Language.ENGLISH,
|
|
showCaptions: false,
|
|
showDashboard: true,
|
|
dashboardRails: formBuilder.group({
|
|
hero: DEFAULT_DASHBOARD_RAILS_SETTINGS.hero,
|
|
continueWatching: DEFAULT_DASHBOARD_RAILS_SETTINGS.continueWatching,
|
|
liveFavorites: DEFAULT_DASHBOARD_RAILS_SETTINGS.liveFavorites,
|
|
recentlyWatchedLive:
|
|
DEFAULT_DASHBOARD_RAILS_SETTINGS.recentlyWatchedLive,
|
|
favoriteMoviesAndSeries:
|
|
DEFAULT_DASHBOARD_RAILS_SETTINGS.favoriteMoviesAndSeries,
|
|
recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources,
|
|
xtreamRecentlyAdded:
|
|
DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded,
|
|
}),
|
|
startupBehavior: StartupBehavior.FirstView,
|
|
showExternalPlaybackBar: true,
|
|
theme: Theme.SystemTheme,
|
|
mpvPlayerPath: '',
|
|
mpvPlayerArguments: '',
|
|
mpvReuseInstance: false,
|
|
vlcPlayerPath: '',
|
|
vlcPlayerArguments: '',
|
|
vlcReuseInstance: false,
|
|
remoteControl: false,
|
|
remoteControlPort: [
|
|
8765,
|
|
[
|
|
Validators.required,
|
|
Validators.min(1),
|
|
Validators.max(65535),
|
|
Validators.pattern(/^\d+$/),
|
|
],
|
|
],
|
|
recordingFolder: '',
|
|
coverSize: 'medium' as CoverSize,
|
|
...(supportsEpg
|
|
? {
|
|
preferUploadedEpgOverXtream: false,
|
|
epgViewMode: 'timeline' as EpgViewMode,
|
|
}
|
|
: {}),
|
|
tmdb: formBuilder.group({
|
|
enabled: DEFAULT_TMDB_SETTINGS.enabled,
|
|
apiKey: DEFAULT_TMDB_SETTINGS.apiKey ?? '',
|
|
}),
|
|
});
|
|
}
|
|
|
|
export type SettingsForm = ReturnType<typeof createSettingsForm>;
|
|
|
|
export function applyEpgUrlsToFormArray(
|
|
epgUrl: FormArray,
|
|
epgUrls: string[] | string
|
|
): void {
|
|
const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls];
|
|
const filteredUrls = urls
|
|
.map((url) => url.trim())
|
|
.filter((url) => url !== '');
|
|
|
|
filteredUrls.forEach((url) => {
|
|
epgUrl.push(createEpgUrlControl(url));
|
|
});
|
|
}
|
|
|
|
export function createSettingsFromFormValue(
|
|
settingsForm: SettingsForm,
|
|
currentSettings: Settings
|
|
): Settings {
|
|
const value = settingsForm.getRawValue();
|
|
const epgUrl = Array.isArray(value.epgUrl)
|
|
? value.epgUrl.filter((url): url is string => typeof url === 'string')
|
|
: (currentSettings.epgUrl ?? []);
|
|
|
|
return {
|
|
player: value.player ?? VideoPlayer.VideoJs,
|
|
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
|
|
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
|
|
language: value.language ?? Language.ENGLISH,
|
|
showCaptions: value.showCaptions ?? false,
|
|
showDashboard: value.showDashboard ?? true,
|
|
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
|
|
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
|
|
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
|
|
theme: value.theme ?? Theme.SystemTheme,
|
|
mpvPlayerPath: normalizeExternalPlayerPath(value.mpvPlayerPath),
|
|
mpvPlayerArguments: normalizeExternalPlayerArguments(
|
|
value.mpvPlayerArguments
|
|
),
|
|
mpvReuseInstance: value.mpvReuseInstance ?? false,
|
|
vlcPlayerPath: normalizeExternalPlayerPath(value.vlcPlayerPath),
|
|
vlcPlayerArguments: normalizeExternalPlayerArguments(
|
|
value.vlcPlayerArguments
|
|
),
|
|
vlcReuseInstance: value.vlcReuseInstance ?? false,
|
|
remoteControl: value.remoteControl ?? false,
|
|
remoteControlPort: Number(value.remoteControlPort ?? 8765),
|
|
recordingFolder: value.recordingFolder ?? '',
|
|
coverSize: value.coverSize ?? 'medium',
|
|
epgUrl,
|
|
preferUploadedEpgOverXtream:
|
|
value.preferUploadedEpgOverXtream ??
|
|
currentSettings.preferUploadedEpgOverXtream ??
|
|
false,
|
|
epgViewMode:
|
|
value.epgViewMode ?? currentSettings.epgViewMode ?? 'timeline',
|
|
trustedPrivateNetworkEpgUrls:
|
|
currentSettings.trustedPrivateNetworkEpgUrls ?? [],
|
|
trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [],
|
|
tmdb: {
|
|
enabled: value.tmdb?.enabled ?? DEFAULT_TMDB_SETTINGS.enabled,
|
|
apiKey: value.tmdb?.apiKey?.trim() ?? '',
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeExternalPlayerPath(
|
|
playerPath: string | null | undefined
|
|
): string {
|
|
return playerPath?.trim() ?? '';
|
|
}
|