From f7bb3a13db5d238007f511ed86c1a4ef0896b882 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:18:06 +0200 Subject: [PATCH] feat(playlist): open recognized M3U movies in the VOD detail view (#1420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3U entries recognized as movie files now open in the portals' two-state VOD detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first: activation still plays immediately, with plot, cast, rating and artwork below the player; Escape reveals the Browse hero. Recognition is a synchronous URL-shape heuristic (movie container extension or an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker names keep today's live layout), gated on TMDB enrichment plus the new default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA. Review follow-ups included: the playback payload no longer carries TMDB fields (its identity is the player's source-application key), the persisted volume reaches the player and survives Browse → Play, the enrichment guard keys on the full lookup identity, and the saved engine mounts first time instead of briefly falling back to Video.js. Co-Authored-By: Claude Fable 5 --- .changes/playlist-m3u-movie-details.md | 10 + CLAUDE.md | 16 + apps/web-e2e/src/m3u-movie-details.e2e.ts | 250 +++++++++++ .../src/app/settings/settings-form.utils.ts | 2 + .../settings-tmdb-section.component.html | 30 +- .../settings-tmdb-section.component.spec.ts | 42 ++ .../settings-tmdb-section.component.ts | 15 +- .../test-stubs/settings-test-harness.stub.ts | 1 + apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/m3u-playlist-module.md | 70 ++++ docs/architecture/tmdb-metadata-enrichment.md | 66 +-- .../m3u-vod-detail.component.html | 99 +++++ .../m3u-vod-detail.component.scss | 26 ++ .../m3u-vod-detail.component.spec.ts | 345 +++++++++++++++ .../m3u-vod-detail.component.ts | 190 +++++++++ .../m3u-vod-metadata.service.spec.ts | 166 ++++++++ .../m3u-vod-metadata.service.ts | 93 +++++ .../video-player-movie-gate.spec.ts | 392 ++++++++++++++++++ .../video-player/video-player.component.html | 220 +++++----- .../video-player/video-player.component.ts | 98 ++++- .../src/lib/settings-store.service.ts | 4 + libs/services/src/lib/tmdb/index.ts | 1 + .../interfaces/src/lib/settings.interface.ts | 8 + libs/shared/m3u-utils/src/index.ts | 1 + .../src/lib/m3u-vod-detection.util.spec.ts | 157 +++++++ .../src/lib/m3u-vod-detection.util.ts | 128 ++++++ .../portal-inline-player-sources.spec.ts | 2 + .../portal-inline-player-up-next.spec.ts | 2 + .../portal-inline-player.component.html | 2 + .../portal-inline-player.component.spec.ts | 2 + .../portal-inline-player.component.ts | 14 + 49 files changed, 2344 insertions(+), 146 deletions(-) create mode 100644 .changes/playlist-m3u-movie-details.md create mode 100644 apps/web-e2e/src/m3u-movie-details.e2e.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.scss create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts create mode 100644 libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.spec.ts create mode 100644 libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts diff --git a/.changes/playlist-m3u-movie-details.md b/.changes/playlist-m3u-movie-details.md new file mode 100644 index 000000000..da459361f --- /dev/null +++ b/.changes/playlist-m3u-movie-details.md @@ -0,0 +1,10 @@ +--- +type: feature +area: playlist +--- + +M3U entries that are movie files now open in the same movie detail view the +portals use: playback starts immediately with plot, cast, rating and artwork +from TMDB below the player instead of the empty program guide, and closing the +player reveals the full poster page. Requires TMDB metadata to be enabled; a +toggle in Settings → Metadata (TMDB) turns recognition off. diff --git a/CLAUDE.md b/CLAUDE.md index 4323acd79..3cf567336 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -485,6 +485,21 @@ Key radio behavior: - Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute - Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` +**M3U Movie Recognition** (VOD detail instead of the EPG zone): an M3U entry +recognized as a movie FILE swaps the player + EPG area for the portals' +two-state VOD detail shell fed by TMDB, watch-first (activation still plays +immediately; Esc reveals the Browse hero). Detection is synchronous URL-shape +heuristics — movie container extension (`mkv`/`mp4`/…, never `ts`/`m3u8`/`mpd`) +or an Xtream-style `/movie|movies|vod/` path segment; radio, DASH, `/series/` +paths and episode-marker names (`S01E02`, "2 серия") fail toward the live +layout (`isLikelyM3uMovie` in `libs/shared/m3u-utils`). Gated on TMDB +enrichment being enabled AND `Settings.m3uVodDetails` (default on; checkbox in +Settings → Metadata (TMDB)). Host: `m3u-vod-detail/` in +`libs/playlist/m3u/feature-player` (shell + `PortalInlinePlayerComponent`, +parent's `embeddedPlayback()` with `isLive: false`); external MPV/VLC users +keep Browse. See "Movie Recognition (VOD Detail View)" in +`docs/architecture/m3u-playlist-module.md`. + Channel List Component Structure (parent coordinator pattern): ``` @@ -1294,6 +1309,7 @@ engine` (restart required) or **TMDB Metadata Enrichment** (opt-in): - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works +- The M3U player consumes it too: entries recognized as movie files open in the VOD detail shell fed purely by `enrichMovie` (no provider payload to merge); the extra `Settings.m3uVodDetails` toggle (default on) sits in the TMDB settings section — see "M3U Movie Recognition" above - "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation - Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url` diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts new file mode 100644 index 000000000..a163b4656 --- /dev/null +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -0,0 +1,250 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +/** + * The M3U movie-recognition workflow end to end: a playlist entry whose URL + * looks like a movie file opens the VOD detail experience instead of the + * player + EPG zone, TMDB metadata patches it asynchronously, and the + * watch ↔ browse transitions keep the persisted volume. + * + * Playback is asserted through the real component composition (detail shell → + * inline player → web player view → engine) but never decodes: the workflow + * claims are about layout, metadata and volume, so a decodable fixture would + * only add flakiness. + */ + +const FIXTURE_HOST = 'https://m3u-movie-fixture.local'; +const MOVIE_PLAYLIST = [ + '#EXTM3U', + '#EXTINF:-1 group-title="Movies",Dune (2021) 1080p', + `${FIXTURE_HOST}/dune.mp4`, + '#EXTINF:-1 tvg-id="live-one" group-title="News",Live One', + `${FIXTURE_HOST}/live.m3u8`, +].join('\n'); + +const TMDB_MOVIE_ID = 438631; + +test.use({ serviceWorkers: 'block' }); + +async function serveTmdb(page: Page): Promise { + await page.route('https://api.themoviedb.org/**', async (route) => { + const url = new URL(route.request().url()); + const json = (body: unknown) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + + if (url.pathname.endsWith('/search/movie')) { + return json({ + results: [ + { + id: TMDB_MOVIE_ID, + title: 'Dune', + original_title: 'Dune', + release_date: '2021-09-15', + vote_count: 9000, + vote_average: 7.8, + }, + ], + }); + } + + if (url.pathname.endsWith(`/movie/${TMDB_MOVIE_ID}`)) { + return json({ + id: TMDB_MOVIE_ID, + title: 'Dune', + overview: 'Paul Atreides arrives on Arrakis.', + release_date: '2021-09-15', + runtime: 155, + vote_average: 7.8, + vote_count: 9000, + genres: [{ id: 878, name: 'Science Fiction' }], + credits: { + cast: [{ id: 1, name: 'Timothee Chalamet', order: 0 }], + crew: [ + { id: 2, name: 'Denis Villeneuve', job: 'Director' }, + ], + }, + }); + } + + // /configuration (the settings "check key" button) and anything else + return json({}); + }); +} + +async function serveStreams(page: Page): Promise { + await page.route(`${FIXTURE_HOST}/**`, (route) => + route.fulfill({ + status: 200, + contentType: 'video/mp4', + body: Buffer.alloc(0), + }) + ); +} + +async function saveSettings(page: Page): Promise { + const saveButton = page.getByRole('button', { name: 'Save changes' }); + await saveButton.click(); + await expect(saveButton).toBeHidden(); +} + +async function selectHtml5Player(page: Page): Promise { + await page.goto('/workspace/settings/playback'); + await page.locator('[data-test-id="select-video-player"]').click(); + await page + .getByRole('option', { name: 'HTML5 video player', exact: true }) + .click(); + await saveSettings(page); +} + +async function enableTmdb(page: Page): Promise { + await page.goto('/workspace/settings/tmdb'); + await page.locator('[data-test-id="tmdb-enabled"] input').check(); + await page.locator('[data-test-id="tmdb-api-key"]').fill('e2e-key'); + // The M3U recognition toggle only appears once TMDB itself is on, and it + // ships enabled — assert rather than click, so a changed default fails. + await expect( + page.locator('[data-test-id="tmdb-m3u-vod-details"] input') + ).toBeChecked(); + await saveSettings(page); +} + +async function importPlaylist(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog + .getByLabel('Insert m3u(8) playlist as text') + .fill(MOVIE_PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('2 channels')).toBeVisible(); +} + +const detail = (page: Page) => page.locator('app-m3u-vod-detail'); +const inlineVideo = (page: Page) => + page.locator('app-m3u-vod-detail app-portal-inline-player video'); +// The sidebar numbers every entry ("1. Dune (2021) 1080p"), so match the +// row by its own test id and filter on the name rather than on exact text. +const sidebarEntry = (page: Page, name: string) => + page.locator('[data-test-id="channel-item"]').filter({ hasText: name }); + +test('@web @m3u @tmdb recognized movies open the VOD detail view', async ({ + page, +}) => { + await serveTmdb(page); + await serveStreams(page); + await selectHtml5Player(page); + await enableTmdb(page); + await importPlaylist(page); + + // Record every engine that is ever attached. A polling assertion cannot + // see the defect this guards: mounting Video.js first and correcting to + // the saved engine a tick later leaves the same final DOM. + await page.evaluate(() => { + const seen = new Set(); + (window as unknown as { __enginesSeen: Set }).__enginesSeen = + seen; + const record = () => { + for (const selector of [ + 'app-vjs-player', + 'app-html-video-player', + ]) { + if (document.querySelector(selector)) { + seen.add(selector); + } + } + }; + record(); + new MutationObserver(record).observe(document.body, { + childList: true, + subtree: true, + }); + }); + + // Watch-first: activating the entry plays immediately inside the detail + // shell, with no EPG zone in sight. + await sidebarEntry(page, 'Dune (2021) 1080p').click(); + await expect(detail(page)).toBeVisible(); + await expect(inlineVideo(page)).toBeVisible(); + await expect(page.locator('app-epg-timeline')).toHaveCount(0); + // The engine is part of the application token, so the saved player must + // mount FIRST TIME — a late correction swaps the player mid-playback. + await expect(detail(page).locator('app-html-video-player')).toHaveCount(1); + expect( + await page.evaluate(() => [ + ...(window as unknown as { __enginesSeen: Set }) + .__enginesSeen, + ]) + ).toEqual(['app-html-video-player']); + + // Metadata patches the mounted view asynchronously. The shell stamps the + // host templates into BOTH the hero and the watch-state About block, so + // every metadata string legitimately resolves twice. + await expect( + detail(page).getByText('Paul Atreides arrives on Arrakis.').first() + ).toBeVisible(); + await expect( + detail(page).getByText('Denis Villeneuve').first() + ).toBeVisible(); + await expect( + detail(page).getByText('Science Fiction').first() + ).toBeVisible(); + + // A live channel keeps the classic layout. + await sidebarEntry(page, 'Live One').click(); + await expect(detail(page)).toHaveCount(0); +}); + +test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ + page, +}) => { + await serveTmdb(page); + await serveStreams(page); + await selectHtml5Player(page); + await enableTmdb(page); + await importPlaylist(page); + + // A volume persisted before the movie was ever opened must reach the + // player — the regression that shipped the detail view without it. + await page.evaluate(() => localStorage.setItem('volume', '0.5')); + + await sidebarEntry(page, 'Dune (2021) 1080p').click(); + await expect(inlineVideo(page)).toBeVisible(); + await expect + .poll(() => + inlineVideo(page).evaluate( + (video: HTMLVideoElement) => video.volume + ) + ) + .toBe(0.5); + + // Stand in for the user moving the engine's own volume slider: the + // engines persist straight to this bus and never call back, which is + // exactly why the remount below has to re-read it. (That the engines + // write on `volumechange` is covered by their own unit tests.) + await page.evaluate(() => localStorage.setItem('volume', '0.25')); + + await page.keyboard.press('Escape'); + await expect(inlineVideo(page)).toHaveCount(0); + const playButton = page.locator('[data-test-id="m3u-vod-play"]'); + await expect(playButton).toBeVisible(); + + // Browse → Play remounts the engine without a channel change. + await playButton.click(); + await expect(inlineVideo(page)).toBeVisible(); + await expect + .poll(() => + inlineVideo(page).evaluate( + (video: HTMLVideoElement) => video.volume + ) + ) + .toBe(0.25); +}); diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 3632e6c0c..df5c727db 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -35,6 +35,7 @@ export function createSettingsForm( playerAmbientMode: false, playerUpNextRail: true, vodAutoFailover: false, + m3uVodDetails: true, ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), @@ -125,6 +126,7 @@ export function createSettingsFromFormValue( playerAmbientMode: value.playerAmbientMode ?? false, playerUpNextRail: value.playerUpNextRail ?? true, vodAutoFailover: value.vodAutoFailover ?? false, + m3uVodDetails: value.m3uVodDetails ?? true, streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false, language: value.language ?? Language.ENGLISH, diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html index a720c1832..c273acb47 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.html +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -1,8 +1,4 @@ -
+
movie @@ -98,6 +94,30 @@
} + @if (form().value.tmdb?.enabled === true) { + @if (m3uVodDetailsControl; as m3uVodDetails) { +
+
+

+ {{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }} +

+

+ {{ + 'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + } +

{{ 'SETTINGS.TMDB_CACHE' | translate }}

diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts b/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts index b0c152a1b..4ed0ccbcb 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts @@ -15,6 +15,7 @@ describe('SettingsTmdbSectionComponent', () => { const createForm = () => new FormGroup({ + m3uVodDetails: new FormControl(true), tmdb: new FormGroup({ enabled: new FormControl(true), apiKey: new FormControl(''), @@ -86,6 +87,47 @@ describe('SettingsTmdbSectionComponent', () => { fixture.detectChanges(); }); + it('offers the M3U movie-recognition toggle while TMDB is enabled', async () => { + await settle(); + + const checkbox = queryByTestId('tmdb-m3u-vod-details'); + expect(checkbox).not.toBeNull(); + + (checkbox?.querySelector('input') as HTMLInputElement).click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.m3uVodDetailsControl?.value).toBe( + false + ); + }); + + it('hides the M3U toggle when TMDB is disabled', async () => { + await settle(); + + fixture.componentInstance.form().get('tmdb.enabled')?.setValue(false); + fixture.detectChanges(); + + expect(queryByTestId('tmdb-m3u-vod-details')).toBeNull(); + }); + + it('hides the M3U toggle row when the host form lacks the control', async () => { + fixture.destroy(); + fixture = TestBed.createComponent(SettingsTmdbSectionComponent); + fixture.componentRef.setInput( + 'form', + new FormGroup({ + tmdb: new FormGroup({ + enabled: new FormControl(true), + apiKey: new FormControl(''), + }), + }) + ); + fixture.detectChanges(); + await settle(); + + expect(queryByTestId('tmdb-m3u-vod-details')).toBeNull(); + }); + it('sizes the cache as soon as the section page opens', async () => { await settle(); diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index f01087a17..172fbd26b 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -5,7 +5,7 @@ import { signal, ViewEncapsulation, } from '@angular/core'; -import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -102,6 +102,18 @@ export class SettingsTmdbSectionComponent { return (this.form().value.tmdb?.apiKey ?? '').trim(); } + /** + * The M3U movie-recognition toggle lives at the settings ROOT (it is a + * player-layout preference, not part of the persisted `tmdb` group this + * section's `formGroupName` wraps), so the template binds it via + * `[formControl]` instead of a group-relative `formControlName`. + * Nullable: the template hides the row for a form without the control. + */ + get m3uVodDetailsControl(): FormControl | null { + const control = this.form().get('m3uVodDetails'); + return control instanceof FormControl ? control : null; + } + async testApiKey(): Promise { const apiKey = this.enteredApiKey; if (!apiKey || this.keyTestState() === 'testing') { @@ -152,5 +164,4 @@ export class SettingsTmdbSectionComponent { this.cacheError.set(stats === null); this.cacheStats.set(stats); } - } diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index a755d261c..ae93a61e5 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -88,6 +88,7 @@ export const DEFAULT_SETTINGS = { playerAmbientMode: false, playerUpNextRail: true, vodAutoFailover: false, + m3uVodDetails: true, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b5201dcaf..55dfd402d 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "مفتاح TMDB API (اختياري)", "TMDB_API_KEY_DESCRIPTION": "استخدم مفتاح TMDB API الخاص بك بدلاً من المفتاح المدمج", "TMDB_GET_KEY_HINT": "احصل على مفتاح API مجاني على themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "التعرّف على الأفلام في قوائم تشغيل M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "عناصر قائمة التشغيل التي تبدو كملفات أفلام تُفتح في صفحة تفاصيل الفيلم مع بيانات TMDB الوصفية بدلاً من لوحة دليل البرامج.", "TMDB_TEST_BUTTON": "التحقق من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح يعمل — استجاب TMDB بنجاح", "TMDB_TEST_FAILED": "فشل التحقق — رفض TMDB المفتاح أو تعذر الوصول إليه", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c4f5b4881..c1f6b0551 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "مفتاح TMDB API (اختياري)", "TMDB_API_KEY_DESCRIPTION": "استعمل مفتاح TMDB API ديالك بلاصة المفتاح المدمج", "TMDB_GET_KEY_HINT": "جيب مفتاح API مجاني من themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "التعرف على الأفلام فقوائم M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "العناصر ديال القائمة اللي كيبانو بحال ملفات أفلام كيتفتحو فصفحة تفاصيل الفيلم مع ميتاداتا TMDB بلاصة لوحة دليل البرامج.", "TMDB_TEST_BUTTON": "تأكد من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح خدام — TMDB جاوب مزيان", "TMDB_TEST_FAILED": "التحقق ما نجحش — TMDB رفض المفتاح ولا ما يمكنش الوصول ليه", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 9df491d9e..8b2eb88ed 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "Ключ TMDB API (неабавязкова)", "TMDB_API_KEY_DESCRIPTION": "Выкарыстоўвайце ўласны ключ TMDB API замест убудаванага", "TMDB_GET_KEY_HINT": "Атрымайце бясплатны API-ключ на themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Распазнаваць фільмы ў плэйлістах M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Элементы плэйліста, падобныя на файлы фільмаў, адкрываюцца на старонцы дэталяў фільма з метаданымі TMDB замест панэлі тэлепраграмы.", "TMDB_TEST_BUTTON": "Праверыць ключ", "TMDB_TEST_SUCCESS": "Ключ працуе — TMDB паспяхова адказаў", "TMDB_TEST_FAILED": "Праверка не ўдалася — TMDB адхіліў ключ або недаступны", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 96485d45d..5edc4f043 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB-API-Schlüssel (optional)", "TMDB_API_KEY_DESCRIPTION": "Eigenen TMDB-API-Schlüssel anstelle des integrierten verwenden", "TMDB_GET_KEY_HINT": "Kostenlosen API-Schlüssel auf themoviedb.org erhalten", + "TMDB_M3U_VOD_DETAILS": "Filme in M3U-Playlists erkennen", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Playlist-Einträge, die wie Filmdateien aussehen, öffnen sich in der Film-Detailansicht mit TMDB-Metadaten anstelle des EPG-Bereichs.", "TMDB_TEST_BUTTON": "Schlüssel prüfen", "TMDB_TEST_SUCCESS": "Der Schlüssel funktioniert — TMDB hat erfolgreich geantwortet", "TMDB_TEST_FAILED": "Prüfung fehlgeschlagen — TMDB hat den Schlüssel abgelehnt oder ist nicht erreichbar", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index d8e278435..2dd11412a 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "Κλειδί API TMDB (προαιρετικό)", "TMDB_API_KEY_DESCRIPTION": "Χρησιμοποιήστε το δικό σας κλειδί API TMDB αντί για το ενσωματωμένο", "TMDB_GET_KEY_HINT": "Αποκτήστε δωρεάν κλειδί API στο themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Αναγνώριση ταινιών σε λίστες αναπαραγωγής M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Οι καταχωρήσεις λίστας που μοιάζουν με αρχεία ταινιών ανοίγουν στην προβολή λεπτομερειών ταινίας με μεταδεδομένα TMDB αντί για τον πίνακα EPG.", "TMDB_TEST_BUTTON": "Έλεγχος κλειδιού", "TMDB_TEST_SUCCESS": "Το κλειδί λειτουργεί — το TMDB απάντησε με επιτυχία", "TMDB_TEST_FAILED": "Ο έλεγχος απέτυχε — το TMDB απέρριψε το κλειδί ή δεν είναι προσβάσιμο", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 22e1751e1..1d55bcd2a 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API key (optional)", "TMDB_API_KEY_DESCRIPTION": "Use your own TMDB API key instead of the built-in one", "TMDB_GET_KEY_HINT": "Get a free API key on themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Recognize movies in M3U playlists", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Playlist entries that look like movie files open in the movie detail view with TMDB metadata instead of the program guide panel.", "TMDB_TEST_BUTTON": "Check key", "TMDB_TEST_SUCCESS": "The key works — TMDB responded successfully", "TMDB_TEST_FAILED": "Check failed — TMDB rejected the key or is unreachable", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4c46088fb..a6ea616f0 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "Clave de API de TMDB (opcional)", "TMDB_API_KEY_DESCRIPTION": "Usa tu propia clave de API de TMDB en lugar de la integrada", "TMDB_GET_KEY_HINT": "Obtén una clave de API gratuita en themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Reconocer películas en listas M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Las entradas de la lista que parecen archivos de películas se abren en la vista de detalles de la película con metadatos de TMDB en lugar del panel de EPG.", "TMDB_TEST_BUTTON": "Comprobar clave", "TMDB_TEST_SUCCESS": "La clave funciona — TMDB respondió correctamente", "TMDB_TEST_FAILED": "La comprobación falló — TMDB rechazó la clave o no está disponible", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 633c17d41..b0fd26b18 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "Clé API TMDB (optionnelle)", "TMDB_API_KEY_DESCRIPTION": "Utiliser votre propre clé API TMDB au lieu de celle intégrée", "TMDB_GET_KEY_HINT": "Obtenez une clé API gratuite sur themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Reconnaître les films dans les listes M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Les entrées de liste qui ressemblent à des fichiers de films s'ouvrent dans la vue détaillée du film avec les métadonnées TMDB au lieu du panneau EPG.", "TMDB_TEST_BUTTON": "Vérifier la clé", "TMDB_TEST_SUCCESS": "La clé fonctionne — TMDB a répondu avec succès", "TMDB_TEST_FAILED": "Échec de la vérification — TMDB a rejeté la clé ou est injoignable", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 4dea2eae4..6db627ab2 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API-kulcs (nem kötelező)", "TMDB_API_KEY_DESCRIPTION": "A beépített helyett saját TMDB API-kulcs használata", "TMDB_GET_KEY_HINT": "Ingyenes API-kulcs igénylése a themoviedb.org webhelyen", + "TMDB_M3U_VOD_DETAILS": "Filmek felismerése az M3U lejátszási listákban", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "A filmfájlnak tűnő listaelemek a film adatlapján nyílnak meg TMDB-metaadatokkal az EPG panel helyett.", "TMDB_TEST_BUTTON": "Kulcs ellenőrzése", "TMDB_TEST_SUCCESS": "A kulcs működik – a TMDB sikeresen válaszolt", "TMDB_TEST_FAILED": "Az ellenőrzés sikertelen – a TMDB elutasította a kulcsot, vagy nem érhető el", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 6341b95f6..4b7cb18dd 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "Chiave API TMDB (opzionale)", "TMDB_API_KEY_DESCRIPTION": "Usa la tua chiave API TMDB al posto di quella integrata", "TMDB_GET_KEY_HINT": "Ottieni una chiave API gratuita su themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Riconosci i film nelle playlist M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Le voci della playlist che sembrano file di film si aprono nella vista di dettaglio del film con i metadati TMDB al posto del pannello EPG.", "TMDB_TEST_BUTTON": "Verifica chiave", "TMDB_TEST_SUCCESS": "La chiave funziona — TMDB ha risposto correttamente", "TMDB_TEST_FAILED": "Verifica non riuscita — TMDB ha rifiutato la chiave o non è raggiungibile", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 6fae6f715..2f0bf9559 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API キー(任意)", "TMDB_API_KEY_DESCRIPTION": "内蔵のキーの代わりに独自の TMDB API キーを使用します", "TMDB_GET_KEY_HINT": "themoviedb.org で無料の API キーを取得", + "TMDB_M3U_VOD_DETAILS": "M3U プレイリスト内の映画を認識", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "映画ファイルと思われるプレイリスト項目は、EPG パネルの代わりに TMDB メタデータ付きの映画詳細画面で開きます。", "TMDB_TEST_BUTTON": "キーを確認", "TMDB_TEST_SUCCESS": "キーは有効です — TMDB が正常に応答しました", "TMDB_TEST_FAILED": "確認に失敗しました — TMDB がキーを拒否したか、接続できません", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 29225b983..013fbd714 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API 키 (선택 사항)", "TMDB_API_KEY_DESCRIPTION": "기본 제공 키 대신 자신의 TMDB API 키를 사용합니다", "TMDB_GET_KEY_HINT": "themoviedb.org에서 무료 API 키 받기", + "TMDB_M3U_VOD_DETAILS": "M3U 재생목록에서 영화 인식", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "영화 파일로 보이는 재생목록 항목은 EPG 패널 대신 TMDB 메타데이터가 포함된 영화 상세 보기로 열립니다.", "TMDB_TEST_BUTTON": "키 확인", "TMDB_TEST_SUCCESS": "키가 작동합니다 — TMDB가 정상적으로 응답했습니다", "TMDB_TEST_FAILED": "확인에 실패했습니다 — TMDB가 키를 거부했거나 연결할 수 없습니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index f153a26fb..84a5d3a98 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API-sleutel (optioneel)", "TMDB_API_KEY_DESCRIPTION": "Gebruik je eigen TMDB API-sleutel in plaats van de ingebouwde", "TMDB_GET_KEY_HINT": "Vraag een gratis API-sleutel aan op themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Films in M3U-afspeellijsten herkennen", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Afspeellijstitems die op filmbestanden lijken, openen in de filmdetailweergave met TMDB-metadata in plaats van het EPG-paneel.", "TMDB_TEST_BUTTON": "Sleutel controleren", "TMDB_TEST_SUCCESS": "De sleutel werkt — TMDB heeft succesvol gereageerd", "TMDB_TEST_FAILED": "Controle mislukt — TMDB heeft de sleutel geweigerd of is niet bereikbaar", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 8033b3e9b..46c9c5766 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "Klucz API TMDB (opcjonalnie)", "TMDB_API_KEY_DESCRIPTION": "Użyj własnego klucza API TMDB zamiast wbudowanego", "TMDB_GET_KEY_HINT": "Uzyskaj bezpłatny klucz API na themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Rozpoznawaj filmy na playlistach M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Pozycje playlisty wyglądające jak pliki filmowe otwierają się w widoku szczegółów filmu z metadanymi TMDB zamiast panelu EPG.", "TMDB_TEST_BUTTON": "Sprawdź klucz", "TMDB_TEST_SUCCESS": "Klucz działa — TMDB odpowiedziało poprawnie", "TMDB_TEST_FAILED": "Sprawdzenie nie powiodło się — TMDB odrzuciło klucz lub jest niedostępne", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index bc56b2be6..e85a110aa 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "Chave de API do TMDB (opcional)", "TMDB_API_KEY_DESCRIPTION": "Use sua própria chave de API do TMDB em vez da chave integrada", "TMDB_GET_KEY_HINT": "Obtenha uma chave de API gratuita em themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Reconhecer filmes em playlists M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "As entradas da playlist que parecem ficheiros de filmes abrem na vista de detalhes do filme com metadados do TMDB em vez do painel de EPG.", "TMDB_TEST_BUTTON": "Verificar chave", "TMDB_TEST_SUCCESS": "A chave funciona — o TMDB respondeu com sucesso", "TMDB_TEST_FAILED": "A verificação falhou — o TMDB rejeitou a chave ou está inacessível", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 984ffed32..c20b72a80 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "API-ключ TMDB (необязательно)", "TMDB_API_KEY_DESCRIPTION": "Использовать собственный API-ключ TMDB вместо встроенного", "TMDB_GET_KEY_HINT": "Получите бесплатный API-ключ на themoviedb.org", + "TMDB_M3U_VOD_DETAILS": "Распознавать фильмы в плейлистах M3U", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Элементы плейлиста, похожие на файлы фильмов, открываются на странице деталей фильма с метаданными TMDB вместо панели телепрограммы.", "TMDB_TEST_BUTTON": "Проверить ключ", "TMDB_TEST_SUCCESS": "Ключ работает — TMDB успешно ответил", "TMDB_TEST_FAILED": "Проверка не удалась — TMDB отклонил ключ или недоступен", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f108a3845..419632b63 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API anahtarı (isteğe bağlı)", "TMDB_API_KEY_DESCRIPTION": "Yerleşik anahtar yerine kendi TMDB API anahtarınızı kullanın", "TMDB_GET_KEY_HINT": "themoviedb.org üzerinden ücretsiz API anahtarı alın", + "TMDB_M3U_VOD_DETAILS": "M3U oynatma listelerindeki filmleri tanı", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Film dosyasına benzeyen oynatma listesi öğeleri, EPG paneli yerine TMDB meta verileriyle film ayrıntı görünümünde açılır.", "TMDB_TEST_BUTTON": "Anahtarı kontrol et", "TMDB_TEST_SUCCESS": "Anahtar çalışıyor — TMDB başarıyla yanıt verdi", "TMDB_TEST_FAILED": "Kontrol başarısız — TMDB anahtarı reddetti veya erişilemiyor", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 6bbafacca..be45a8848 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API 密钥(可选)", "TMDB_API_KEY_DESCRIPTION": "使用您自己的 TMDB API 密钥替代内置密钥", "TMDB_GET_KEY_HINT": "在 themoviedb.org 获取免费的 API 密钥", + "TMDB_M3U_VOD_DETAILS": "识别 M3U 播放列表中的电影", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "疑似电影文件的播放列表条目会在包含 TMDB 元数据的电影详情视图中打开,而不是 EPG 面板。", "TMDB_TEST_BUTTON": "检查密钥", "TMDB_TEST_SUCCESS": "密钥有效 — TMDB 响应成功", "TMDB_TEST_FAILED": "检查失败 — TMDB 拒绝了该密钥或无法访问", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index d9e1ff643..b2749ef5c 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -264,6 +264,8 @@ "TMDB_API_KEY": "TMDB API 金鑰(選填)", "TMDB_API_KEY_DESCRIPTION": "使用您自己的 TMDB API 金鑰,而非內建金鑰", "TMDB_GET_KEY_HINT": "在 themoviedb.org 取得免費的 API 金鑰", + "TMDB_M3U_VOD_DETAILS": "辨識 M3U 播放清單中的電影", + "TMDB_M3U_VOD_DETAILS_DESCRIPTION": "看似電影檔案的播放清單項目會在包含 TMDB 中繼資料的電影詳細資訊頁面中開啟,而非 EPG 面板。", "TMDB_TEST_BUTTON": "檢查金鑰", "TMDB_TEST_SUCCESS": "金鑰有效 — TMDB 回應成功", "TMDB_TEST_FAILED": "檢查失敗 — TMDB 拒絕該金鑰或無法連線", diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 0b2c3aaa8..e2061bcfe 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1062,6 +1062,76 @@ bypass Playwright interception) and `electron-backend-e2e:src/dash-clearkey.e2e.ts` (real ClearKey EME in Electron). +## Movie Recognition (VOD Detail View) + +M3U playlists routinely mix live channels with movie FILES ("Movies"/"VOD" +groups, Xtream-derived exports). A recognized movie swaps the player + EPG +zone for the same two-state VOD detail shell the portals use, fed by TMDB +metadata. Works in both Electron and the PWA. + +**Gate** (`VideoPlayerComponent.showMovieDetail`): the branch activates only +when ALL hold — the URL-shape heuristic recognizes the channel, TMDB +enrichment is enabled (`TmdbEnrichmentService.isEnabled()`), and +`Settings.m3uVodDetails` is not `false` (default on; the checkbox lives in +Settings → Metadata (TMDB) under the master toggle). The decision is +**synchronous** — layout is chosen at activation; the async TMDB lookup only +patches metadata into an already-mounted view, so a missed match never causes +a layout jump. + +**Detection** (`isLikelyM3uMovie` in +`libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts`): a movie-file +container extension (`mkv`, `mp4`, `avi`, … — deliberately NOT `ts`/`m3u8`/ +`m4s` which deliver live streams, and not `mpd` which has its own DASH path) +OR an Xtream-style `/movie/`, `/movies/`, `/vod/` path segment. Exclusions +fail toward today's live layout: `radio="true"`, DASH URLs, `/series/` paths, +and names carrying an episode marker (`S01E02`, `1x02`, "2 серия", +"Season 3" — the season word list is shared with the title normalizer). +Known accepted cost: "Star Wars: Episode 1" is skipped. + +**Flow** (`m3u-vod-detail/` in `libs/playlist/m3u/feature-player`): + +- `M3uVodDetailComponent` hosts `PortalDetailShellComponent` + + `PortalInlinePlayerComponent`. **Watch-first**: clicking a channel keeps + M3U zapping semantics — playback starts immediately, the About block + renders below where the EPG zone would be; Escape/close reveals the Browse + hero, Play re-enters watch. The hero back arrow is hidden (the channel + sidebar next to the detail IS the navigation). +- The parent passes its already-built `embeddedPlayback()` + (`ResolvedPortalPlayback` with headers/EXTVLCOPT resolved) plus its shared + persisted `volume()`; the host only overrides `isLive: false` (seek + bar/VOD semantics). The payload's OBJECT IDENTITY is the player's + source-application key (`createWebPlayerApplicationState` mints a new + source revision for any new payload), so it must never depend on TMDB + signals — folding the resolved title/poster in there restarted the movie + the moment enrichment landed. Metadata lives in the hero/About + presentation only. +- `PortalInlinePlayerComponent` gained an optional `volume` input + (default `1`) for this host: the M3U player owns a persisted volume shared + across its channels, while the portals keep the engines' own default. The + parent re-reads that bus per channel AND on the host's `playbackStarted` + output, because Browse → Play remounts the engine without a channel change + and the engines only ever write to the bus. + +**Coverage:** `apps/web-e2e/src/m3u-movie-details.e2e.ts` drives the workflow +against the real composition with a routed TMDB mock — recognition, async +metadata, the live-channel fallback, and the volume the remount must keep. + +- `M3uVodMetadataService` (component-provided) calls + `TmdbEnrichmentService.enrichMovie({ title: channel.name, year: +releaseTagYear(channel.name) })` — the resolver already normalizes titles + and caches verdicts; the service only adds the release-year hint and a + staleness guard keyed on `channel.id` so zapping mid-request drops the + stale resolution. +- No TMDB match keeps the thin provider presentation (entry name + logo) in + the detail layout rather than jumping back to the EPG view. +- External MPV/VLC users keep Browse: the m3u-state effects launch the + external player on activation exactly as before, and + `inlinePlayerAvailable=false` means the host never mounts an inline player. + +**Out of scope (v1)**: series episodes (detection skips them), playback +position persistence for M3U movies, clickable cast chips (no +`actor/:personId` route in the M3U tree), and downloads. + ## Interfaces ### Channel Interface diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 728cb6640..a11cca36a 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -17,6 +17,12 @@ Related: cast, director, genres, rating, artwork) via a **field-level merge** — the provider stays authoritative for stream-related data and for any field TMDB cannot fill. +- The M3U player is a consumer too: an entry recognized as a movie file opens + in the VOD detail shell fed purely by an `enrichMovie` lookup (there is no + provider payload to merge into). Gated by the additional + `Settings.m3uVodDetails` toggle (default on) below the master switch; see + "Movie Recognition (VOD Detail View)" in + `docs/architecture/m3u-playlist-module.md`. - Enrichment is **opt-in** via `Settings > Metadata (TMDB)` because it sends movie/series titles to a third-party API. Default: disabled. - The detail view renders provider data **immediately**; enrichment runs @@ -109,12 +115,13 @@ Wrong metadata is worse than no metadata, so id resolution is conservative: "chapter two"), so a name mismatch alone says more about our inputs than about the id. - A 404 is the one hard verdict: the id is recorded as dead - (`badProviderId:` row), skipped next time, and the title search - takes over. Transient failures (auth, rate limit, 5xx, offline) neither - disable the id nor trigger a search — that request would hit the same - outage, and a title match that did come back would be weaker evidence - than the id already in hand. + A 404 is the one hard verdict: the id is recorded as dead + (`badProviderId:` row), skipped next time, and the title search + takes over. Transient failures (auth, rate limit, 5xx, offline) neither + disable the id nor trigger a search — that request would hit the same + outage, and a title match that did come back would be weaker evidence + than the id already in hand. + 2. Otherwise `/search/movie` (or `/search/tv`) runs with the normalized title. Normalization strips bracketed tags, quality markers (`4K`, `1080p`, `MULTI`, …), leading language prefixes (`EN - `), diacritics, @@ -407,7 +414,10 @@ supports CORS so the PWA calls the API directly). `Settings.tmdb?: { enabled: boolean; apiKey?: string }` (`libs/shared/interfaces/src/lib/tmdb.interface.ts`). The settings page has a "Metadata (TMDB)" section: enable toggle, optional API key override with a -"check key" button (validates against `/configuration`), and a cache panel +"check key" button (validates against `/configuration`), the M3U +movie-recognition toggle (root-level `Settings.m3uVodDetails`, shown only +while TMDB is enabled and bound via `[formControl]` because it is not part of +the `tmdb` form group), and a cache panel showing the stored row count plus payload size with a button that drops the lot. Sizing is a full table scan, so it runs only once that section is the active one, and a failed read or clear says so instead of showing an empty @@ -544,27 +554,27 @@ since shipped.) position (no TMDB involved); the watch-progress bar is limited to movie/series heroes. - The query is built to **match what the detail view searched with**, not - just what the card displays. A title alone is weaker identity than the - detail page had: without a year `pickConfidentMatch` requires a single - exact title match, which common titles never satisfy, and the miss lands - in the negative cache under a lookup key the detail view's hit can never - be found at. Stalker rows carry those facts — - `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads - `info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the - stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field. - A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what - every row falls back to when nothing says otherwise, and an embedded-VOD - series is a `'movie'` activity row but a show on TMDB. That retry **drops - the id**, which is only ever valid for the media type it was resolved - under: `/movie/` resolves to an unrelated film. A `'tv'` verdict - gets no retry — it is reached only on positive evidence (series category, - `is_series`, or a non-empty episode array), and the gate cannot tell an - adaptation sharing its show's name and year from the show itself. + The query is built to **match what the detail view searched with**, not + just what the card displays. A title alone is weaker identity than the + detail page had: without a year `pickConfidentMatch` requires a single + exact title match, which common titles never satisfy, and the miss lands + in the negative cache under a lookup key the detail view's hit can never + be found at. Stalker rows carry those facts — + `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads + `info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the + stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field. + A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what + every row falls back to when nothing says otherwise, and an embedded-VOD + series is a `'movie'` activity row but a show on TMDB. That retry **drops + the id**, which is only ever valid for the media type it was resolved + under: `/movie/` resolves to an unrelated film. A `'tv'` verdict + gets no retry — it is reached only on positive evidence (series category, + `is_series`, or a non-empty episode array), and the gate cannot tell an + adaptation sharing its show's name and year from the show itself. - Note the id in a stored Stalker entry is not a provider claim. Stalker - portals never send one; its only source is a match this app already made - and gated. + Note the id in a stored Stalker entry is not a provider claim. Stalker + portals never send one; its only source is a match this app already made + and gated. ### Stalker backdrops on activity rows @@ -588,7 +598,7 @@ negative-match TTL. Only a **confirmed 404** is recorded. Transient failures (401, 429, 5xx, offline) leave no marker — they say nothing about the id. Neither does a -title mismatch: that id exists and may be correct for a *different* item, +title mismatch: that id exists and may be correct for a _different_ item, and since the row is keyed by id alone and shared across playlists, recording per-item mismatches here would deny the direct lookup to every other item that legitimately uses the same id. diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html new file mode 100644 index 000000000..4936ba425 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html @@ -0,0 +1,99 @@ + + + @if (year()) { + {{ year() }} + } + @if (genres()) { + {{ genres() }} + } + @if (runtimeLabel()) { + {{ runtimeLabel() }} + } + @if (rating()) { + + ⭐ {{ rating() }} + + } + + + + @if (cast().length) { +
+ {{ 'XTREAM.ACTORS' | translate }} +
+ @for ( + member of cast(); + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index + ) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+
+ } + @if (directors()) { +
+ {{ 'XTREAM.DIRECTOR' | translate }} + {{ directors() }} +
+ } +
+ + + @if (canPlayInline() && !playbackActive()) { + + } + + + @if (inlinePlayback(); as playback) { + + } +
diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.scss b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.scss new file mode 100644 index 000000000..7daca014c --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.scss @@ -0,0 +1,26 @@ +@use '../../../../../../ui/styles/detail-view' as detail-view; + +@include detail-view.base(); + +:host { + display: block; + flex: 1; + height: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +app-portal-detail-shell { + display: block; + height: 100%; +} + +// The M3U player page has no route to go "back" to — the channel sidebar +// next to the detail IS the navigation — so the hero's back arrow would +// dead-end. Hidden here instead of a shell option: this host is the only +// consumer without a parent route. `:host` scoping is load-bearing — a bare +// `::ng-deep` would leak the rule globally and hide the portals' back arrow. +:host ::ng-deep .hero__back-button { + display: none; +} diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts new file mode 100644 index 000000000..b29fa15a3 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts @@ -0,0 +1,345 @@ +import { Component, input, output } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { Channel, ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { TranslatePipe } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import type { M3uVodDetailComponent as M3uVodDetailComponentType } from './m3u-vod-detail.component'; + +// The component's import chain reaches video.js through the ui/playback +// barrel (PortalInlinePlayerComponent → WebPlayerView → VjsPlayer); the CJS +// bundle breaks under the ESM jest environment, so it is mocked before the +// dynamic import below evaluates the chain. +jest.unstable_mockModule('video.js', () => ({ + default: jest.fn(), +})); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +@Component({ + selector: 'app-portal-detail-shell', + standalone: true, + template: '', +}) +class StubPortalDetailShellComponent { + readonly title = input(); + readonly description = input(); + readonly posterUrl = input(); + readonly backdropUrl = input(); + readonly isLoading = input(false); + readonly errorMessage = input(); + readonly backLabel = input(); + readonly playbackActive = input(false); + readonly backClicked = output(); + readonly closePlayerRequested = output(); +} + +@Component({ + selector: 'app-portal-inline-player', + standalone: true, + template: '', +}) +class StubPortalInlinePlayerComponent { + readonly playbackSessionKey = input.required(); + readonly playback = input(null); + readonly volume = input(1); + readonly playerOverride = input(null); + readonly closed = output(); + readonly externalFallbackRequested = output(); +} + +const channel = (overrides: Partial = {}): Channel => + ({ + id: 'ch1', + name: 'Dune (2021) 1080p', + url: 'http://host/movie/user/pass/1.mkv', + group: { title: 'Movies' }, + tvg: { + id: '', + name: '', + url: '', + logo: 'http://logo/dune.png', + rec: '', + }, + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: '', + ...overrides, + }) as Channel; + +const playback = ( + overrides: Partial = {} +): ResolvedPortalPlayback => + ({ + streamUrl: 'http://host/movie/user/pass/1.mkv', + title: 'Dune (2021) 1080p', + thumbnail: 'http://logo/dune.png', + isLive: true, + ...overrides, + }) as ResolvedPortalPlayback; + +describe('M3uVodDetailComponent', () => { + let M3uVodDetailComponent: typeof M3uVodDetailComponentType; + let fixture: ComponentFixture; + let enrichMovie: jest.Mock; + + beforeAll(async () => { + ({ M3uVodDetailComponent } = + await import('./m3u-vod-detail.component')); + }); + + const flush = async () => { + await new Promise((res) => setTimeout(res, 0)); + fixture.detectChanges(); + }; + + const create = async (inputs: { + channel: Channel; + playback?: ResolvedPortalPlayback | null; + inlinePlayerAvailable?: boolean; + volume?: number; + }) => { + fixture = TestBed.createComponent(M3uVodDetailComponent); + fixture.componentRef.setInput('channel', inputs.channel); + fixture.componentRef.setInput('playback', inputs.playback ?? null); + fixture.componentRef.setInput('playbackSessionKey', 'live:p1:ch1'); + if (inputs.inlinePlayerAvailable !== undefined) { + fixture.componentRef.setInput( + 'inlinePlayerAvailable', + inputs.inlinePlayerAvailable + ); + } + if (inputs.volume !== undefined) { + fixture.componentRef.setInput('volume', inputs.volume); + } + fixture.detectChanges(); + await flush(); + }; + + const inlinePlayerStub = () => + fixture.debugElement.query( + By.directive(StubPortalInlinePlayerComponent) + )?.componentInstance as StubPortalInlinePlayerComponent | undefined; + + beforeEach(async () => { + enrichMovie = jest.fn().mockResolvedValue(null); + + await TestBed.configureTestingModule({ + imports: [M3uVodDetailComponent], + providers: [ + { + provide: TmdbEnrichmentService, + useValue: { + enrichMovie, + isEnabled: jest.fn().mockReturnValue(true), + }, + }, + ], + }) + // `set` replaces the whole imports list, so the real shell and + // inline player never need importing here — a static (or even + // dynamic) barrel import would mark those libs lazy-loaded for + // the module-boundaries rule workspace-wide. + .overrideComponent(M3uVodDetailComponent, { + set: { + imports: [ + StubPortalDetailShellComponent, + StubPortalInlinePlayerComponent, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('starts in watch state — activation means play in M3U', async () => { + await create({ channel: channel(), playback: playback() }); + + expect(fixture.componentInstance.playbackActive()).toBe(true); + expect( + fixture.nativeElement.querySelector('app-portal-inline-player') + ).not.toBeNull(); + }); + + it('plays with VOD semantics and the parent payload', async () => { + await create({ channel: channel(), playback: playback() }); + + const inline = fixture.componentInstance.inlinePlayback(); + expect(inline?.isLive).toBe(false); + expect(inline?.streamUrl).toBe('http://host/movie/user/pass/1.mkv'); + expect(inline?.title).toBe('Dune (2021) 1080p'); + }); + + it("forwards the host's persisted volume to the player", async () => { + await create({ + channel: channel(), + playback: playback(), + volume: 0.35, + }); + + expect(inlinePlayerStub()?.volume()).toBe(0.35); + }); + + it('forwards the resolved engine so the player never mounts a wrong one', async () => { + await create({ channel: channel(), playback: playback() }); + fixture.componentRef.setInput('playerOverride', 'html5'); + fixture.detectChanges(); + + expect(inlinePlayerStub()?.playerOverride()).toBe('html5'); + }); + + it('keeps the playback payload identical when TMDB metadata lands', async () => { + // Payload identity is the player's source-application key: a new + // object recreates the player and restarts the movie. Enrichment must + // never reach it — only the presentation around it. + let resolveEnrichment!: (details: unknown) => void; + enrichMovie.mockReturnValue( + new Promise((resolve) => { + resolveEnrichment = resolve; + }) + ); + await create({ channel: channel(), playback: playback() }); + + const before = fixture.componentInstance.inlinePlayback(); + resolveEnrichment({ + id: 42, + title: 'Dune', + poster_path: '/p.jpg', + backdrop_path: '/b.jpg', + }); + await flush(); + + expect(fixture.componentInstance.title()).toBe('Dune'); + expect(fixture.componentInstance.inlinePlayback()).toBe(before); + }); + + it('patches the view when the TMDB match lands', async () => { + enrichMovie.mockResolvedValue({ + id: 42, + title: 'Dune', + overview: 'Spice.', + poster_path: '/p.jpg', + backdrop_path: '/b.jpg', + release_date: '2021-09-15', + runtime: 155, + vote_average: 8.04, + vote_count: 1000, + genres: [{ id: 1, name: 'Sci-Fi' }], + credits: { + cast: [{ name: 'Timothée Chalamet', order: 0 }], + crew: [{ name: 'Denis Villeneuve', job: 'Director' }], + }, + }); + await create({ channel: channel(), playback: playback() }); + + const component = fixture.componentInstance; + expect(component.title()).toBe('Dune'); + expect(component.overview()).toBe('Spice.'); + expect(component.posterUrl()).toContain('/p.jpg'); + expect(component.backdropUrl()).toContain('/b.jpg'); + expect(component.year()).toBe('2021'); + expect(component.genres()).toBe('Sci-Fi'); + expect(component.runtimeLabel()).toBe('2h 35m'); + expect(component.rating()).toBe('8.0'); + expect(component.cast().map((member) => member.name)).toEqual([ + 'Timothée Chalamet', + ]); + expect(component.directors()).toBe('Denis Villeneuve'); + }); + + it('keeps the provider presentation when TMDB has no match', async () => { + enrichMovie.mockResolvedValue(null); + await create({ channel: channel(), playback: playback() }); + + const component = fixture.componentInstance; + expect(component.title()).toBe('Dune (2021) 1080p'); + expect(component.posterUrl()).toBe('http://logo/dune.png'); + expect(component.backdropUrl()).toBeUndefined(); + expect(component.playbackActive()).toBe(true); + }); + + it('restarts the payload identity when zapping to another movie', async () => { + await create({ channel: channel(), playback: playback() }); + const first = fixture.componentInstance.inlinePlayback(); + + fixture.componentRef.setInput( + 'playback', + playback({ streamUrl: 'http://host/movie/user/pass/2.mkv' }) + ); + fixture.detectChanges(); + + expect(fixture.componentInstance.inlinePlayback()).not.toBe(first); + expect(fixture.componentInstance.inlinePlayback()?.streamUrl).toBe( + 'http://host/movie/user/pass/2.mkv' + ); + }); + + it('asks the host to refresh volume before remounting the player', async () => { + await create({ channel: channel(), playback: playback() }); + const started = jest.fn(); + fixture.componentInstance.playbackStarted.subscribe(started); + + fixture.componentInstance.closeInlinePlayback(); + fixture.detectChanges(); + fixture.componentInstance.startPlayback(); + fixture.detectChanges(); + + // Browse → Play remounts the engine without a channel change, and the + // engines only write their own volume to the shared bus. + expect(started).toHaveBeenCalledTimes(1); + }); + + it('close reveals browse with a Play action; Play re-enters watch', async () => { + await create({ channel: channel(), playback: playback() }); + + fixture.componentInstance.closeInlinePlayback(); + fixture.detectChanges(); + expect(fixture.componentInstance.playbackActive()).toBe(false); + expect( + fixture.nativeElement.querySelector('app-portal-inline-player') + ).toBeNull(); + expect(fixture.componentInstance.canPlayInline()).toBe(true); + + fixture.componentInstance.startPlayback(); + fixture.detectChanges(); + expect(fixture.componentInstance.playbackActive()).toBe(true); + }); + + it('re-enters watch when zapping to the next movie after a close', async () => { + await create({ channel: channel(), playback: playback() }); + fixture.componentInstance.closeInlinePlayback(); + fixture.detectChanges(); + + fixture.componentRef.setInput( + 'channel', + channel({ id: 'ch2', name: 'Alien' }) + ); + fixture.detectChanges(); + await flush(); + + expect(fixture.componentInstance.playbackActive()).toBe(true); + expect(enrichMovie).toHaveBeenCalledTimes(2); + }); + + it('never mounts an inline player for external MPV/VLC users', async () => { + await create({ + channel: channel(), + playback: playback(), + inlinePlayerAvailable: false, + }); + + expect(fixture.componentInstance.playbackActive()).toBe(false); + expect(fixture.componentInstance.canPlayInline()).toBe(false); + expect( + fixture.nativeElement.querySelector('app-portal-inline-player') + ).toBeNull(); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts new file mode 100644 index 000000000..a55dc2b02 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts @@ -0,0 +1,190 @@ +import { + Component, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; +import { + enrichedCast, + tmdbBackdropUrl, + tmdbPosterUrl, + topCast, +} from '@iptvnator/services'; +import { + Channel, + ResolvedPortalPlayback, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + PortalDetailShellComponent, +} from '@iptvnator/ui/components'; +import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; +import { M3uVodMetadataService } from './m3u-vod-metadata.service'; + +/** + * VOD detail experience for an M3U entry recognized as a movie: the same + * two-state shell the portals use, fed by TMDB metadata instead of a portal + * payload. Watch-first — the parent activates a channel and playback starts + * immediately (M3U zapping semantics), with the About block below where the + * EPG zone would be; Escape/close reveals the Browse hero. + * + * The shell renders provider facts (entry name, logo) instantly; the TMDB + * lookup patches the same view asynchronously, so a missing or failed match + * simply leaves the thin provider presentation in place — no layout jump. + * + * External MPV/VLC users keep the Browse layout: the m3u-state effects + * already launched the player on activation, so `inlinePlayerAvailable` is + * false and this component never mounts an inline player for them. + */ +@Component({ + selector: 'app-m3u-vod-detail', + providers: [M3uVodMetadataService], + imports: [ + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + MatIcon, + PortalDetailShellComponent, + PortalInlinePlayerComponent, + TranslatePipe, + ], + templateUrl: './m3u-vod-detail.component.html', + styleUrls: ['./m3u-vod-detail.component.scss'], +}) +export class M3uVodDetailComponent { + private readonly metadata = inject(M3uVodMetadataService); + + readonly channel = input.required(); + /** Parent-owned playback payload (headers/DRM already resolved). */ + readonly playback = input(null); + readonly playbackSessionKey = input.required(); + /** Parent's `shouldShowInlinePlayer` verdict — false for MPV/VLC users. */ + readonly inlinePlayerAvailable = input(true); + /** M3U's shared persisted volume (localStorage `volume`). */ + readonly volume = input(1); + /** + * Engine the parent already resolved from settings. Without it the shared + * player waits for its own asynchronous settings read and mounts Video.js + * meanwhile, then swaps — the live layout passes the same value. + */ + readonly playerOverride = input(null); + + readonly externalFallbackRequested = output(); + /** + * A player is about to be mounted for the current channel. The host owns + * the persisted volume and re-reads it per channel, but Browse → Play + * remounts the engine without a channel change, and the engines only + * WRITE their own adjustments to the shared bus — so without this the + * remount would restore the volume from before the user touched it. + */ + readonly playbackStarted = output(); + + /** Player closed by the user — Browse until Play or the next channel. */ + private readonly playerDismissed = signal(false); + + constructor() { + effect(() => { + const channel = this.channel(); + untracked(() => { + this.playerDismissed.set(false); + this.metadata.load(channel); + }); + }); + } + + readonly tmdb = computed(() => this.metadata.state().details); + + readonly title = computed( + () => this.tmdb()?.title?.trim() || this.channel().name + ); + readonly overview = computed(() => this.tmdb()?.overview?.trim() ?? ''); + readonly posterUrl = computed( + () => + tmdbPosterUrl(this.tmdb()?.poster_path) ?? + this.channel().tvg?.logo?.trim() ?? + undefined + ); + readonly backdropUrl = computed( + () => tmdbBackdropUrl(this.tmdb()?.backdrop_path) ?? undefined + ); + readonly year = computed(() => { + const releaseDate = this.tmdb()?.release_date ?? ''; + return /^\d{4}/.test(releaseDate) ? releaseDate.slice(0, 4) : ''; + }); + readonly genres = computed(() => + (this.tmdb()?.genres ?? []) + .map((genre) => genre.name) + .filter(Boolean) + .join(', ') + ); + readonly runtimeLabel = computed(() => { + const runtime = this.tmdb()?.runtime; + if (!runtime || runtime <= 0) { + return ''; + } + const hours = Math.floor(runtime / 60); + const minutes = runtime % 60; + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + }); + readonly rating = computed(() => { + const details = this.tmdb(); + const average = details?.vote_average ?? 0; + const count = details?.vote_count ?? 0; + return average > 0 && count > 0 + ? (Math.round(average * 10) / 10).toFixed(1) + : ''; + }); + readonly cast = computed(() => { + const credits = this.tmdb()?.credits; + return credits ? enrichedCast(topCast(credits)) : []; + }); + readonly directors = computed(() => + (this.tmdb()?.credits?.crew ?? []) + .filter((member) => member.job === 'Director') + .map((member) => member.name) + .join(', ') + ); + + /** + * The playback payload handed to the player. Its OBJECT IDENTITY is the + * source-application key downstream (`createWebPlayerApplicationState` + * mints a new source revision for any new payload, which recreates the + * player and re-applies the source), so it must depend on the parent's + * payload alone. Reading TMDB signals here would restart the movie a + * couple of seconds in, the moment enrichment resolves — metadata belongs + * in the About/hero presentation, never in the source identity. + * + * The parent built the payload for a LIVE channel; the recognized movie + * only flips to VOD semantics (seek bar, duration). + */ + readonly inlinePlayback = computed(() => { + if (!this.inlinePlayerAvailable() || this.playerDismissed()) { + return null; + } + const playback = this.playback(); + return playback ? { ...playback, isLive: false } : null; + }); + readonly playbackActive = computed(() => this.inlinePlayback() !== null); + readonly canPlayInline = computed( + () => this.inlinePlayerAvailable() && this.playback() !== null + ); + + closeInlinePlayback(): void { + this.playerDismissed.set(true); + } + + startPlayback(): void { + this.playbackStarted.emit(); + this.playerDismissed.set(false); + } +} diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.spec.ts new file mode 100644 index 000000000..7c4c59614 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.spec.ts @@ -0,0 +1,166 @@ +import { TestBed } from '@angular/core/testing'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { M3uVodMetadataService } from './m3u-vod-metadata.service'; + +describe('M3uVodMetadataService', () => { + let service: M3uVodMetadataService; + let enrichMovie: jest.Mock; + let isEnabled: jest.Mock; + + /** Deferred promise so tests control exactly when a lookup settles. */ + const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; + + const flush = () => new Promise((res) => setTimeout(res, 0)); + + beforeEach(() => { + enrichMovie = jest.fn(); + isEnabled = jest.fn().mockReturnValue(true); + + TestBed.configureTestingModule({ + providers: [ + M3uVodMetadataService, + { + provide: TmdbEnrichmentService, + useValue: { enrichMovie, isEnabled }, + }, + ], + }); + service = TestBed.inject(M3uVodMetadataService); + }); + + it('resolves a match and exposes the details', async () => { + const details = { id: 42, title: 'Dune' }; + enrichMovie.mockResolvedValue(details); + + service.load({ id: 'ch1', name: 'Dune (2021) 1080p' }); + expect(service.state().status).toBe('loading'); + + await flush(); + expect(service.state()).toEqual({ + lookupKey: 'ch1\u0000Dune (2021) 1080p', + status: 'matched', + details, + }); + }); + + it('passes the raw name and the release-tag year to the resolver', () => { + enrichMovie.mockResolvedValue(null); + + service.load({ id: 'ch1', name: 'Dune (2021) 1080p' }); + + expect(enrichMovie).toHaveBeenCalledWith({ + title: 'Dune (2021) 1080p', + year: 2021, + }); + }); + + it('never reads a year that is part of the film name', () => { + enrichMovie.mockResolvedValue(null); + + service.load({ id: 'ch1', name: '2001: A Space Odyssey' }); + + expect(enrichMovie).toHaveBeenCalledWith({ + title: '2001: A Space Odyssey', + year: null, + }); + }); + + it('reports "none" when the resolver finds no confident match', async () => { + enrichMovie.mockResolvedValue(null); + + service.load({ id: 'ch1', name: 'Some Obscure Movie' }); + await flush(); + + expect(service.state().status).toBe('none'); + expect(service.state().details).toBeNull(); + }); + + it('reports "none" when the lookup rejects', async () => { + enrichMovie.mockRejectedValue(new Error('offline')); + + service.load({ id: 'ch1', name: 'Dune' }); + await flush(); + + expect(service.state().status).toBe('none'); + }); + + it('drops a stale resolution after zapping to another channel', async () => { + const first = deferred<{ id: number; title: string } | null>(); + const second = deferred<{ id: number; title: string } | null>(); + enrichMovie + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + service.load({ id: 'ch1', name: 'Dune' }); + service.load({ id: 'ch2', name: 'Alien' }); + + // The FIRST channel's answer arrives after the switch — must not land + first.resolve({ id: 1, title: 'Dune' }); + await flush(); + expect(service.state().status).toBe('loading'); + expect(service.state().lookupKey).toContain('ch2'); + + second.resolve({ id: 2, title: 'Alien' }); + await flush(); + expect(service.state()).toEqual({ + lookupKey: 'ch2\u0000Alien', + status: 'matched', + details: { id: 2, title: 'Alien' }, + }); + }); + + it('re-requests when a shared id carries a different title', async () => { + // `createChannel` falls back to the URL for a missing id, so two + // entries pointing at one stream can share one — the lookup identity + // is the id AND the title the request is actually built from. + enrichMovie.mockResolvedValue({ id: 1, title: 'Dune' }); + service.load({ id: 'same-id', name: 'Dune' }); + await flush(); + + service.load({ id: 'same-id', name: 'Alien' }); + + expect(enrichMovie).toHaveBeenCalledTimes(2); + expect(service.state().status).toBe('loading'); + }); + + it('does not re-request the channel the state already tracks', async () => { + enrichMovie.mockResolvedValue({ id: 1, title: 'Dune' }); + + service.load({ id: 'ch1', name: 'Dune' }); + await flush(); + service.load({ id: 'ch1', name: 'Dune' }); + + expect(enrichMovie).toHaveBeenCalledTimes(1); + }); + + it('settles as "none" without a request while TMDB is disabled', () => { + isEnabled.mockReturnValue(false); + + service.load({ id: 'ch1', name: 'Dune' }); + + expect(service.state().status).toBe('none'); + expect(enrichMovie).not.toHaveBeenCalled(); + }); + + it('reset returns to idle', async () => { + enrichMovie.mockResolvedValue(null); + service.load({ id: 'ch1', name: 'Dune' }); + await flush(); + + service.reset(); + + expect(service.state()).toEqual({ + lookupKey: null, + status: 'idle', + details: null, + }); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.ts new file mode 100644 index 000000000..c05edeb95 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-metadata.service.ts @@ -0,0 +1,93 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { TmdbEnrichmentService, TmdbMovieDetails } from '@iptvnator/services'; +import { Channel, releaseTagYear } from '@iptvnator/shared/interfaces'; + +export type M3uVodMetadataStatus = 'idle' | 'loading' | 'matched' | 'none'; + +export interface M3uVodMetadataState { + /** + * The lookup this state belongs to — the staleness anchor. Both the id + * AND the title, because the title is what is actually looked up: an id + * alone would assume it determines the name, and nothing guarantees + * that (`createChannel` falls back to the URL for a missing id, so two + * entries pointing at one stream would share it). + */ + lookupKey: string | null; + status: M3uVodMetadataStatus; + details: TmdbMovieDetails | null; +} + +const IDLE_STATE: M3uVodMetadataState = { + lookupKey: null, + status: 'idle', + details: null, +}; + +/** NUL keeps the two parts unambiguous whatever an entry is named. */ +function lookupKeyOf(channel: Pick): string { + return `${channel.id}\u0000${channel.name ?? ''}`; +} + +/** + * TMDB lookup for an M3U entry recognized as a movie. Thin glue over + * {@link TmdbEnrichmentService}: the resolver already normalizes titles + * (brackets, quality tags, language prefixes) and caches verdicts, so the + * only jobs here are the release-year hint and the staleness guard. + * + * Component-provided (not root): the state is scoped to one mounted detail + * host, and dies with it. + */ +@Injectable() +export class M3uVodMetadataService { + private readonly tmdb = inject(TmdbEnrichmentService); + + private readonly stateSignal = signal(IDLE_STATE); + readonly state = this.stateSignal.asReadonly(); + + /** + * Resolve TMDB metadata for the channel. Zapping to another channel + * while a request is in flight makes the stale resolution a no-op — + * only the response for the channel the state currently tracks lands. + */ + load(channel: Pick): void { + const lookupKey = lookupKeyOf(channel); + if (this.stateSignal().lookupKey === lookupKey) { + return; + } + + if (!this.tmdb.isEnabled()) { + this.stateSignal.set({ lookupKey, status: 'none', details: null }); + return; + } + + this.stateSignal.set({ lookupKey, status: 'loading', details: null }); + + // `releaseTagYear` reads only bracketed/trailing release TAGS, never + // a year that is part of the film's name ("2001: A Space Odyssey"). + void this.tmdb + .enrichMovie({ + title: channel.name, + year: releaseTagYear(channel.name), + }) + .then( + (details) => this.settle(lookupKey, details), + () => this.settle(lookupKey, null) + ); + } + + reset(): void { + this.stateSignal.set(IDLE_STATE); + } + + private settle(lookupKey: string, details: TmdbMovieDetails | null): void { + if (this.stateSignal().lookupKey !== lookupKey) { + return; + } + + this.stateSignal.set({ + lookupKey, + status: details ? 'matched' : 'none', + details, + }); + } +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts new file mode 100644 index 000000000..4633093cc --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts @@ -0,0 +1,392 @@ +import { AsyncPipe } from '@angular/common'; +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Overlay } from '@angular/cdk/overlay'; +import { Store } from '@ngrx/store'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslatePipe } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import { BehaviorSubject, of } from 'rxjs'; +import { + selectActive, + selectActiveEpgProgram, + selectActivePlaybackUrl, + selectChannels, + selectChannelsLoading, + selectCurrentEpgProgram, +} from '@iptvnator/m3u-state'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; +import { + DataService, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { + Channel, + EpgProgram, + Settings, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component'; + +// The component's chain reaches video.js (ui/playback → VjsPlayer); the CJS +// bundle breaks under the ESM jest environment, so it is mocked before the +// dynamic import evaluates the chain. +jest.unstable_mockModule('video.js', () => ({ + default: jest.fn(), +})); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +/** + * The M3U movie recognition gate: which channels swap the player + EPG zone + * for the VOD detail host. Separate from `video-player.component.spec.ts` + * only because that file sits at the max-lines test budget; the harness here + * is the minimal subset needed to render the layout branch. + */ +describe('VideoPlayerComponent — M3U movie recognition gate', () => { + let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent; + let fixture: ComponentFixture; + let component: VideoPlayerComponentInstance; + + const playlistId = signal('playlist-1'); + const activeChannel = signal(null); + const activePlaybackUrl = signal(null); + const channels = signal([]); + const channelsLoading = signal(false); + const currentEpgProgram = signal(null); + const activeEpgProgram = signal(null); + const channels$ = new BehaviorSubject([]); + const activeChannel$ = new BehaviorSubject(null); + const currentEpgProgram$ = new BehaviorSubject(null); + const epgPrograms$ = new BehaviorSubject([]); + + const player = signal(VideoPlayer.VideoJs); + const showCaptions = signal(false); + const stripCountryPrefix = signal(false); + const m3uVodDetails = signal(true); + const tmdbEnabled = signal(true); + + const storeMock = { + dispatch: jest.fn(), + selectSignal: jest.fn((selector: unknown) => { + switch (selector) { + case selectActive: + return activeChannel; + case selectActivePlaybackUrl: + return activePlaybackUrl; + case selectChannels: + return channels; + case selectChannelsLoading: + return channelsLoading; + case selectCurrentEpgProgram: + return currentEpgProgram; + case selectActiveEpgProgram: + return activeEpgProgram; + default: + return signal(null); + } + }), + select: jest.fn((selector: unknown) => { + switch (selector) { + case selectChannels: + return channels$.asObservable(); + case selectActive: + return activeChannel$.asObservable(); + case selectCurrentEpgProgram: + return currentEpgProgram$.asObservable(); + default: + return of(null); + } + }), + }; + + const liveChannel: Channel = { + id: 'channel-1', + url: 'http://localhost/live.m3u8', + name: 'Sample TV', + epgParams: '', + radio: 'false', + tvg: { + id: 'sample-tvg-id', + logo: 'http://localhost/logo.png', + name: 'Sample TV', + }, + } as Channel; + + const movieChannel: Channel = { + ...liveChannel, + id: 'movie-1', + name: 'Dune (2021) 1080p', + url: 'http://host/movie/user/pass/1.mkv', + tvg: { ...liveChannel.tvg, id: '' }, + }; + + beforeAll(async () => { + ({ VideoPlayerComponent } = await import('./video-player.component')); + }); + + function syncStoreState(channel: Channel | null): void { + activeChannel.set(channel); + activeChannel$.next(channel); + channels.set(channel ? [channel] : []); + channels$.next(channel ? [channel] : []); + } + + beforeEach(async () => { + syncStoreState(null); + m3uVodDetails.set(true); + tmdbEnabled.set(true); + + await TestBed.configureTestingModule({ + imports: [VideoPlayerComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({ id: playlistId(), view: 'all' }), + queryParams: of({}), + snapshot: { + data: { layout: 'workspace' }, + queryParams: {}, + }, + }, + }, + { + provide: Router, + useValue: { + url: '/workspace/playlists/playlist-1/all', + navigate: jest.fn(), + currentNavigation: jest.fn().mockReturnValue(null), + }, + }, + { provide: Store, useValue: storeMock }, + { + provide: Overlay, + useValue: { position: jest.fn(), create: jest.fn() }, + }, + { provide: DataService, useValue: { sendIpcEvent: jest.fn() } }, + { + provide: RuntimeCapabilitiesService, + useValue: { + supportsEpg: true, + isElectron: false, + supportsRemoteControl: false, + }, + }, + { + provide: PlaylistsService, + useValue: { + getPlaylist: jest.fn(() => + of({ playlist: { items: [] }, favorites: [] }) + ), + getPlaylistWithGlobalFavorites: jest.fn(() => + of({ playlist: { items: [] }, favorites: [] }) + ), + addM3uRecentlyViewed: jest.fn(() => + of({ recentlyViewed: [] }) + ), + }, + }, + { + provide: EpgService, + useValue: { + currentEpgPrograms$: epgPrograms$.asObservable(), + getChannelMetadataForChannels: () => of(new Map()), + }, + }, + { + provide: PlaylistContextFacade, + useValue: { resolvedPlaylistId: playlistId }, + }, + { + provide: SettingsStore, + useValue: { + player, + showCaptions, + stripCountryPrefix, + m3uVodDetails, + resolvedEpgViewMode: signal('timeline'), + epgUrl: signal([]), + }, + }, + { + provide: StorageMap, + useValue: { + get: jest.fn(() => + of({ + player: player(), + showCaptions: showCaptions(), + } as Partial) + ), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: () => tmdbEnabled() }, + }, + ], + }) + // The layout branch is the subject; everything below it renders + // as inert unknown elements. + .overrideComponent(VideoPlayerComponent, { + set: { + imports: [ + AsyncPipe, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + schemas: [NO_ERRORS_SCHEMA], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('routes a recognized movie to the VOD detail host instead of the EPG zone', () => { + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.showMovieDetail()).toBe(true); + expect( + fixture.nativeElement.querySelector('app-m3u-vod-detail') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); + expect(fixture.nativeElement.querySelector('.video-player')).toBeNull(); + }); + + it('keeps the live layout for a live channel', () => { + syncStoreState(liveChannel); + fixture.detectChanges(); + + expect(component.showMovieDetail()).toBe(false); + expect( + fixture.nativeElement.querySelector('app-m3u-vod-detail') + ).toBeNull(); + expect(fixture.nativeElement.querySelector('.epg')).not.toBeNull(); + }); + + it('stays off when the settings toggle is disabled', () => { + m3uVodDetails.set(false); + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.showMovieDetail()).toBe(false); + expect( + fixture.nativeElement.querySelector('app-m3u-vod-detail') + ).toBeNull(); + expect(fixture.nativeElement.querySelector('.epg')).not.toBeNull(); + }); + + it('stays off while TMDB enrichment is not enabled', () => { + tmdbEnabled.set(false); + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.showMovieDetail()).toBe(false); + }); + + it('treats a missing toggle value as enabled (default on)', () => { + m3uVodDetails.set(undefined); + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.showMovieDetail()).toBe(true); + }); + + describe('volume handed to each new player', () => { + afterEach(() => localStorage.removeItem('volume')); + + it('re-reads the shared volume bus on every channel change', () => { + localStorage.setItem('volume', '0.4'); + syncStoreState(movieChannel); + fixture.detectChanges(); + expect(component.volume()).toBe(0.4); + + // The engines persist straight to localStorage and never call + // back, so a volume set inside the player is only visible there. + localStorage.setItem('volume', '0.15'); + syncStoreState(liveChannel); + fixture.detectChanges(); + + expect(component.volume()).toBe(0.15); + }); + + it.each(['not-a-number', '', ' ', '5', '-1'])( + 'falls back to full volume for the stored value %p', + (stored) => { + localStorage.setItem('volume', stored); + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.volume()).toBe(1); + } + ); + + it('falls back to full volume when nothing is stored yet', () => { + localStorage.removeItem('volume'); + syncStoreState(movieChannel); + fixture.detectChanges(); + + // `Number(null)` is 0 — a first run must not start muted. + expect(component.volume()).toBe(1); + }); + + it('keeps a stored silence', () => { + localStorage.setItem('volume', '0'); + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.volume()).toBe(0); + }); + + it('refreshes from the bus when the detail host remounts the player', () => { + localStorage.setItem('volume', '0.9'); + syncStoreState(movieChannel); + fixture.detectChanges(); + expect(component.volume()).toBe(0.9); + + // Engine-side change during playback, then Browse → Play on the + // SAME channel: no channel change, so only this call refreshes. + localStorage.setItem('volume', '0.25'); + component.refreshVolumeFromBus(); + + expect(component.volume()).toBe(0.25); + }); + + it('re-reads even when the next entry shares the previous id', () => { + // `createChannel` falls back to the URL for the id, so one stream + // listed under two groups yields two entries with the same id. + // Pins the user-visible guarantee independently of what the + // linkedSignal source happens to derive. + localStorage.setItem('volume', '0.8'); + syncStoreState(movieChannel); + fixture.detectChanges(); + expect(component.volume()).toBe(0.8); + + localStorage.setItem('volume', '0.2'); + syncStoreState({ ...movieChannel, group: { title: 'New' } }); + fixture.detectChanges(); + + expect(component.volume()).toBe(0.2); + }); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 7e52028da..35ae9e3f8 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -64,113 +64,135 @@ } @if (activeChannel(); as activeChannel) { - @if ( - activeChannel.radio === 'true' || - shouldShowInlinePlayer(activeChannel) - ) { -
- @if (activeChannel.radio === 'true') { - - } @else { - - } -
- } - - @if (supportsEpg && activeChannel.radio !== 'true') { - @let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel); -
-
- @if (epgViewMode() === 'list') { - + } @else { + @if ( + activeChannel.radio === 'true' || + shouldShowInlinePlayer(activeChannel) + ) { +
+ @if (activeChannel.radio === 'true') { + } @else { - }
-
+ } + + @if (supportsEpg && activeChannel.radio !== 'true') { + @let canCollapseEpgPanel = + shouldShowInlinePlayer(activeChannel); +
+
+ @if (epgViewMode() === 'list') { + + } @else { + + } +
+
+ } } @if (showChannelNumberOverlay) { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 52f7fe8c3..04ccb2aa3 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -11,6 +11,7 @@ import { computed, effect, inject, + linkedSignal, signal, untracked, } from '@angular/core'; @@ -29,6 +30,7 @@ import { extractDrmFromRaw, isDashChannel, isDashStreamUrl, + isLikelyM3uMovie, isM3uCatchupPlaybackSupported, } from '@iptvnator/shared/m3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; @@ -103,6 +105,7 @@ import { PlaylistsService, RuntimeCapabilitiesService, SettingsStore, + TmdbEnrichmentService, } from '@iptvnator/services'; import { Channel, @@ -119,6 +122,7 @@ import { Settings, VideoPlayer, } from '@iptvnator/shared/interfaces'; +import { M3uVodDetailComponent } from '../m3u-vod-detail/m3u-vod-detail.component'; import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions'; const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg'; @@ -128,6 +132,23 @@ const M3U_SIDEBAR_MIN_WIDTH = 200; const M3U_SIDEBAR_MAX_WIDTH = 600; const M3U_SIDEBAR_DEFAULT_WIDTH = 460; +/** + * Shared `volume` bus the player engines and the audio player persist to. + * + * The empty cases must be rejected BEFORE `Number()` sees them: it maps both + * `null` (nothing stored yet) and `''` to 0, which would silently start every + * first-run playback muted. + */ +function readStoredVolume(): number { + const stored = localStorage.getItem('volume')?.trim(); + if (!stored) { + return 1; + } + + const parsed = Number(stored); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 1; +} + @Component({ selector: 'app-video-player', imports: [ @@ -137,6 +158,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460; CommonModule, EpgListViewComponent, EpgTimelineComponent, + M3uVodDetailComponent, MatButtonModule, MatIconModule, MatTooltipModule, @@ -162,6 +184,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly storage = inject(StorageMap); private readonly store = inject(Store); private readonly epgService = inject(EpgService); + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly workspaceHeaderContext = inject( WorkspaceHeaderContextService @@ -212,6 +235,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ? VideoPlayer.ArtPlayer : VideoPlayer.Html5Player ); + /** + * The active channel is a movie FILE (URL-shape heuristic) and the user + * has TMDB enrichment plus the recognition toggle on — the content area + * shows the VOD detail experience instead of the player + EPG zone. + * Synchronous on purpose: the layout is chosen at activation, and the + * TMDB lookup inside the detail host only patches metadata afterwards. + */ + readonly showMovieDetail = computed(() => { + const channel = this.activeChannel(); + return ( + !!channel && + this.settingsStore.m3uVodDetails?.() !== false && + this.tmdbEnrichment.isEnabled() && + isLikelyM3uMovie(channel) + ); + }); /** Full multi-day programme window for the active channel (timeline). */ readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, { initialValue: [] as EpgProgram[], @@ -435,15 +474,34 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { showChannelNumberOverlay = false; private channelNumberTimeout?: number; - readonly volume = signal(1); + /** + * Volume handed to each new player instance, re-read from the shared + * `volume` localStorage bus on every channel change. + * + * The bus is what the engines actually write to (they persist on + * `volumechange` and never call back into this component), and every + * channel switch mints a new source revision, which recreates the engine + * component and re-reads this input. A plain constructor snapshot + * therefore snapped playback back to the volume the page was opened + * with as soon as the user zapped after adjusting it in the player. + * Remote-control writes still `set()` this signal directly and store the + * same value, so re-reading the bus per channel agrees with them. + * + * The source is the whole channel, not a derived key: `linkedSignal` + * re-runs its computation whenever the source EXPRESSION invalidates — + * the derived value's equality does not gate it (`producerRecomputeValue` + * calls `computation()` right after re-evaluating `source()`). Selecting + * the channel is therefore the trigger, which is exactly the intent, and + * nothing here depends on ids being distinct — `createChannel` falls back + * to the URL, so one stream listed in two groups shares an id. A re-read + * is one idempotent localStorage hit, so an extra one costs nothing. + */ + readonly volume = linkedSignal({ + source: () => this.activeChannel(), + computation: () => readStoredVolume(), + }); constructor() { - // Initialize volume from localStorage in constructor - const savedVolume = localStorage.getItem('volume'); - if (savedVolume !== null) { - this.volume.set(Number(savedVolume)); - } - // React to settings changes effect(() => { this.playerSettings = { @@ -599,7 +657,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { untracked(() => { const remoteControl = this.remoteControlBridge; const activeChannel = this.activeChannel(); - if (!remoteControl?.updateRemoteControlStatus || !activeChannel) { + if ( + !remoteControl?.updateRemoteControlStatus || + !activeChannel + ) { return; } @@ -666,7 +727,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } const currentEpgProgram = epgProgram as - EpgProgram | null | undefined; + | EpgProgram + | null + | undefined; const currentIndex = channels.findIndex( (channel) => channel.url === activeChannel.url ); @@ -1090,6 +1153,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } } + /** + * Pull the latest volume off the shared bus before a player is mounted + * without a channel change (the movie detail's Browse → Play). The + * engines persist their adjustments there and never call back, so the + * remounted player would otherwise start at the pre-adjustment value. + */ + refreshVolumeFromBus(): void { + this.volume.set(readStoredVolume()); + } + private setVolume(next: number): void { const clamped = Math.max(0, Math.min(1, Number(next.toFixed(2)))); this.volume.set(clamped); @@ -1142,7 +1215,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { // after Shaka's browser-support preflight fails — the DASH-forced // inline player is not audible then, so this check must precede the // DASH shortcut. - if (isLiveExternalPlayerSession(this.externalPlayback.activeSession())) { + if ( + isLiveExternalPlayerSession(this.externalPlayback.activeSession()) + ) { return false; } @@ -1152,8 +1227,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { const player = this.playerSettings.player; return ( - !this.isExternalPlayer(player) && - player !== VideoPlayer.EmbeddedMpv + !this.isExternalPlayer(player) && player !== VideoPlayer.EmbeddedMpv ); } diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 7e07930a1..fcbf018de 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -30,6 +30,7 @@ const DEFAULT_SETTINGS: Settings = { playerAmbientMode: false, playerUpNextRail: true, vodAutoFailover: false, + m3uVodDetails: true, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, @@ -226,6 +227,9 @@ export const SettingsStore = signalStore( vodAutoFailover: store.vodAutoFailover?.() ?? DEFAULT_SETTINGS.vodAutoFailover, + m3uVodDetails: + store.m3uVodDetails?.() ?? + DEFAULT_SETTINGS.m3uVodDetails, streamFormat: store.streamFormat(), openStreamOnDoubleClick: store.openStreamOnDoubleClick(), language: store.language(), diff --git a/libs/services/src/lib/tmdb/index.ts b/libs/services/src/lib/tmdb/index.ts index 0b94b3c1f..caf910eeb 100644 --- a/libs/services/src/lib/tmdb/index.ts +++ b/libs/services/src/lib/tmdb/index.ts @@ -1,6 +1,7 @@ export * from './tmdb-api.service'; export * from './tmdb-cache.service'; export * from './tmdb-config'; +export * from './tmdb-credits'; export * from './tmdb-enrichment.service'; export * from './tmdb-id-resolver.service'; export * from './tmdb-episode-merge'; diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 2c1707f48..c8be577c9 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -131,6 +131,14 @@ export interface Settings { * session, so this cannot loop. */ vodAutoFailover?: boolean; + /** + * Present M3U entries recognized as movie files (by URL shape) in the + * VOD detail view with TMDB metadata instead of the EPG zone. On by + * default; missing values mean enabled. Only takes effect while TMDB + * enrichment itself is enabled — without it there is no metadata to + * show, so recognition never runs. + */ + m3uVodDetails?: boolean; epgUrl: string[]; streamFormat: StreamFormat; openStreamOnDoubleClick: boolean; diff --git a/libs/shared/m3u-utils/src/index.ts b/libs/shared/m3u-utils/src/index.ts index e9c180317..032f38839 100644 --- a/libs/shared/m3u-utils/src/index.ts +++ b/libs/shared/m3u-utils/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/dash.utils'; export * from './lib/kodiprop.utils'; +export * from './lib/m3u-vod-detection.util'; export * from './lib/playback-media-extension.util'; export * from './lib/playlist.utils'; export * from './lib/catchup.utils'; diff --git a/libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.spec.ts b/libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.spec.ts new file mode 100644 index 000000000..ebe7b0ec4 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.spec.ts @@ -0,0 +1,157 @@ +import { hasEpisodeMarker, isLikelyM3uMovie } from './m3u-vod-detection.util'; + +const movie = (url: string, name = 'Some Movie', radio = '') => ({ + url, + name, + radio, +}); + +describe('isLikelyM3uMovie', () => { + describe('movie container extensions', () => { + it.each([ + 'http://host/films/Dune.Part.Two.2024.mkv', + 'http://host/films/Dune.mp4', + 'http://host/films/OLDBOY.MKV', + 'http://host/films/movie.avi', + 'http://host/films/movie.webm', + 'http://host/films/clip.m4v', + ])('recognizes %s', (url) => { + expect(isLikelyM3uMovie(movie(url))).toBe(true); + }); + + it('recognizes a query-declared extension', () => { + expect(isLikelyM3uMovie(movie('http://host/stream?ext=mp4'))).toBe( + true + ); + }); + + it.each([ + 'http://host/live/channel.ts', + 'http://host/live/channel.m3u8', + 'http://host/live/segment.m4s', + 'http://host/live/audio.mp3', + 'http://host/live/audio.aac', + 'http://host/live/channel', + ])('does not treat streaming/audio URL %s as a movie', (url) => { + expect(isLikelyM3uMovie(movie(url))).toBe(false); + }); + }); + + describe('xtream-style path segments', () => { + it('recognizes /movie/ paths without a file extension', () => { + expect( + isLikelyM3uMovie(movie('http://host/movie/user/pass/12345')) + ).toBe(true); + }); + + it('recognizes HLS VOD under /movie/', () => { + expect( + isLikelyM3uMovie( + movie('http://host/movie/user/pass/12345.m3u8') + ) + ).toBe(true); + }); + + it.each(['movies', 'vod'])('recognizes /%s/ segment', (segment) => { + expect( + isLikelyM3uMovie(movie(`http://host/${segment}/12345`)) + ).toBe(true); + }); + + it('is case-insensitive about the segment', () => { + expect( + isLikelyM3uMovie(movie('http://host/MOVIE/user/pass/1')) + ).toBe(true); + }); + + it('does not fire on a segment merely containing the word', () => { + expect( + isLikelyM3uMovie(movie('http://host/moviestar-tv/live')) + ).toBe(false); + }); + + it('treats /series/ as episodes, not movies, even with a movie container', () => { + expect( + isLikelyM3uMovie(movie('http://host/series/user/pass/777.mkv')) + ).toBe(false); + }); + }); + + describe('exclusions', () => { + it('never treats a radio entry as a movie', () => { + expect( + isLikelyM3uMovie(movie('http://host/x.mp4', 'Jazz FM', 'true')) + ).toBe(false); + }); + + it('never treats a DASH URL as a movie, even under /movie/', () => { + expect( + isLikelyM3uMovie(movie('http://host/movie/stream.mpd')) + ).toBe(false); + }); + + it('skips entries whose name carries an episode marker', () => { + expect( + isLikelyM3uMovie( + movie('http://host/f/x.mkv', 'Breaking Bad S01E02') + ) + ).toBe(false); + }); + + it('handles null and missing urls', () => { + expect(isLikelyM3uMovie(null)).toBe(false); + expect(isLikelyM3uMovie(undefined)).toBe(false); + expect(isLikelyM3uMovie(movie(''))).toBe(false); + }); + + it('handles unparseable urls without throwing', () => { + expect(isLikelyM3uMovie(movie('http://['))).toBe(false); + }); + }); +}); + +describe('hasEpisodeMarker', () => { + it.each([ + 'Breaking Bad S01E02', + 'Breaking Bad s1.e2', + 'Breaking Bad S01 E02', + 'Breaking Bad 1x02', + 'The Boys Season 3', + 'The Boys season_02', + 'Sherlock Episode 5', + 'Dark Folge 3', + 'La Casa de Papel Episodio 2', + 'Шерлок 2 серия', + 'Во все тяжкие 1 сезон', + 'Пацаны 3-й сезон', + 'Эпизод 4 Шерлок', + ])('detects "%s"', (name) => { + expect(hasEpisodeMarker(name)).toBe(true); + }); + + it.each([ + 'Dune Part Two', + '4x4', + '2001: A Space Odyssey', + '1917', + 'Se7en', + 'Postseason Story', + 'The Fifth Season', + 'Друзья Оушена 13', + '', + ])('does not fire on "%s"', (name) => { + expect(hasEpisodeMarker(name)).toBe(false); + }); + + // Documented cost of the conservative gate: a real film whose NAME + // carries an episode word is skipped and keeps the current live-style + // view. Safe direction — never mis-enrich a series episode as a film. + it('skips "Star Wars: Episode 1" by design', () => { + expect(hasEpisodeMarker('Star Wars: Episode 1')).toBe(true); + }); + + it('handles null/undefined', () => { + expect(hasEpisodeMarker(null)).toBe(false); + expect(hasEpisodeMarker(undefined)).toBe(false); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts b/libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts new file mode 100644 index 000000000..73268a39a --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts @@ -0,0 +1,128 @@ +import { + Channel, + SEASON_WORD_ALTERNATIVES, +} from '@iptvnator/shared/interfaces'; +import { isDashStreamUrl } from './dash.utils'; +import { getPlaybackMediaExtensionFromUrl } from './playback-media-extension.util'; + +/** + * Heuristic that decides whether an M3U entry is a movie FILE rather than a + * live stream, so the player can present the VOD detail experience (TMDB + * metadata instead of the EPG zone). + * + * The decision must be synchronous — the layout is chosen the moment a + * channel is activated, before any TMDB request — so it reads only what the + * playlist itself states: the URL shape and the entry name. It is + * deliberately conservative: a false negative keeps today's behavior, while + * a false positive would swap the EPG zone away from a live channel. + */ + +/** + * File containers that only ever carry finished assets. Streaming + * packagings are deliberately absent: `.ts`/`.m3u8`/`.m4s` are how LIVE + * channels are delivered, and `.mpd` (DASH) has its own routing path. + * Audio extensions are absent too — an audio file is not a movie. + */ +const MOVIE_CONTAINER_EXTENSIONS = new Set([ + 'avi', + 'asf', + 'divx', + 'flv', + 'm2ts', + 'm4v', + 'mkv', + 'mov', + 'mp4', + 'mpeg', + 'mpg', + 'rm', + 'rmvb', + 'vob', + 'webm', + 'wmv', +]); + +/** + * Xtream-derived M3U exports address VOD as `/movie///`, + * often without a file extension. The segment is a strong VOD signal on its + * own, whatever the container (some panels serve HLS VOD under it). + * `/series/` is the same panels' EPISODE namespace — those are series + * content, which v1 does not recognize. + */ +const MOVIE_PATH_SEGMENTS = new Set(['movie', 'movies', 'vod']); +const SERIES_PATH_SEGMENT = 'series'; + +/** + * Episode markers in the entry name: "S01E01", "s1.e2", "1x02", + * "Episode 5", "2 серия", "Season 3" (word list shared with the season + * stripper in title-normalization). A marked entry is a series episode, not + * a movie — v1 skips those rather than mis-enriching them as films. + * Known cost: "Star Wars: Episode 1" is skipped too; that only keeps the + * current live-style view, which is the safe direction. + * + * `(?:^|[^\p{L}])` guards instead of `\b`: JS word boundaries are + * ASCII-only and never fire next to Cyrillic letters. + */ +const EPISODE_WORD_ALTERNATIVES = + 'episode|episodio|folge|серия|эпизод|bölüm|odcinek|aflevering'; +const SEASON_OR_EPISODE_WORD = `(?:${SEASON_WORD_ALTERNATIVES}|${EPISODE_WORD_ALTERNATIVES})`; + +const SEASON_EPISODE_CODE = + /(?:^|[^a-z0-9])s\d{1,2}[\s._-]*e\d{1,3}(?![a-z0-9])/i; +// Requires a 2–3 digit episode part, so short film titles like "4x4" survive +const CROSS_EPISODE_CODE = /(?:^|[^0-9])\d{1,2}x\d{2,3}(?![0-9])/; +const WORD_FIRST_MARKER = new RegExp( + `(?:^|[^\\p{L}])${SEASON_OR_EPISODE_WORD}[\\s._-]*\\d{1,3}(?!\\d)`, + 'iu' +); +const NUMBER_FIRST_MARKER = new RegExp( + `(?:^|[^\\p{L}\\d])\\d{1,2}[\\s._-]*(?:st|nd|rd|th|й|и|я|ой|ои)?[\\s._-]*${SEASON_OR_EPISODE_WORD}(?:$|[^\\p{L}])`, + 'iu' +); + +export function hasEpisodeMarker(name: string | null | undefined): boolean { + if (!name) { + return false; + } + + return ( + SEASON_EPISODE_CODE.test(name) || + CROSS_EPISODE_CODE.test(name) || + WORD_FIRST_MARKER.test(name) || + NUMBER_FIRST_MARKER.test(name) + ); +} + +function pathSegmentsOf(url: string): string[] { + try { + return new URL(url, 'http://iptvnator.local').pathname + .split('/') + .filter((segment) => segment !== '') + .map((segment) => segment.toLowerCase()); + } catch { + return []; + } +} + +export function isLikelyM3uMovie( + channel: Pick | null | undefined +): boolean { + const url = channel?.url; + if (!url || channel.radio === 'true' || isDashStreamUrl(url)) { + return false; + } + + if (hasEpisodeMarker(channel.name)) { + return false; + } + + const segments = pathSegmentsOf(url); + if (segments.includes(SERIES_PATH_SEGMENT)) { + return false; + } + + return ( + MOVIE_CONTAINER_EXTENSIONS.has(getPlaybackMediaExtensionFromUrl(url)) || + segments.some((segment) => MOVIE_PATH_SEGMENTS.has(segment)) + ); +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 4db6ae12e..40064d879 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -26,6 +26,8 @@ class StubWebPlayerViewComponent { readonly title = input(''); readonly mediaTitle = input(null); readonly playback = input(null); + readonly volume = input(1); + readonly playerOverride = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index 7b09f18de..4d6e9124c 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -23,6 +23,8 @@ class StubWebPlayerViewComponent { readonly title = input(''); readonly mediaTitle = input(null); readonly playback = input(null); + readonly volume = input(1); + readonly playerOverride = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 50f3235bf..593f9b881 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -89,6 +89,8 @@ [title]="title()" [mediaTitle]="playerMediaTitle()" [playback]="playback()" + [volume]="volume()" + [playerOverride]="playerOverride()" [startTime]="startTime()" [seriesNavigation]="seriesNavigation()" [alternativeSources]="alternativeSources()" diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index b547f6392..a96f35c2f 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -26,6 +26,8 @@ class StubWebPlayerViewComponent { readonly title = input(''); readonly mediaTitle = input(null); readonly playback = input(null); + readonly volume = input(1); + readonly playerOverride = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 25437a2bc..e05301355 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -72,6 +72,20 @@ export class PortalInlinePlayerComponent { readonly seriesTitle = input(null); /** "Up Next" entries built by the series host; null for movies/live. */ readonly upNextEpisodes = input(null); + /** + * Initial player volume. Only hosts that own a persisted volume pass it + * (the M3U player shares one across its channels); the portals keep the + * engines' own default, which is what this default preserves. + */ + readonly volume = input(1); + /** + * Engine the host already resolved. `WebPlayerViewComponent` otherwise + * waits for its own asynchronous settings read and mounts Video.js + * meanwhile — and the engine is part of the application token, so that + * correction swaps the player under a running session. Hosts that hold + * the settings synchronously pass them; `null` keeps the old behaviour. + */ + readonly playerOverride = input(null); private readonly settingsStore = inject(SettingsStore); // Strip only live-channel titles — VOD/series titles ("Mission: // Impossible - Fallout") must never lose their leading segment.