feat(playlist): open recognized M3U movies in the VOD detail view (#1420)

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-13 17:18:06 +02:00
1 parent 82a2948ffd
commit f7bb3a13db
49 files changed
+2344 -146

No files matched your search

+10
View File
@@ -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.
+16
View File
@@ -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`
+250
View File
@@ -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<void> {
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<void> {
await page.route(`${FIXTURE_HOST}/**`, (route) =>
route.fulfill({
status: 200,
contentType: 'video/mp4',
body: Buffer.alloc(0),
})
);
}
async function saveSettings(page: Page): Promise<void> {
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
}
async function selectHtml5Player(page: Page): Promise<void> {
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<void> {
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<void> {
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<string>();
(window as unknown as { __enginesSeen: Set<string> }).__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<string> })
.__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);
});
@@ -35,6 +35,7 @@ export function createSettingsForm(
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
m3uVodDetails: true,
...(supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
@@ -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,
@@ -1,8 +1,4 @@
<section
class="settings-group"
id="tmdb"
[formGroup]="form()"
>
<section class="settings-group" id="tmdb" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>movie</mat-icon>
@@ -98,6 +94,30 @@
</div>
}
@if (form().value.tmdb?.enabled === true) {
@if (m3uVodDetailsControl; as m3uVodDetails) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
</h4>
<p>
{{
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[formControl]="m3uVodDetails"
data-test-id="tmdb-m3u-vod-details"
></mat-checkbox>
</div>
</div>
}
}
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
@@ -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();
@@ -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<void> {
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);
}
}
@@ -88,6 +88,7 @@ export const DEFAULT_SETTINGS = {
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
m3uVodDetails: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
+2
View File
@@ -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 المفتاح أو تعذر الوصول إليه",
+2
View File
@@ -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 رفض المفتاح ولا ما يمكنش الوصول ليه",
+2
View File
@@ -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 адхіліў ключ або недаступны",
+2
View File
@@ -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",
+2
View File
@@ -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 απέρριψε το κλειδί ή δεν είναι προσβάσιμο",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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 がキーを拒否したか、接続できません",
+2
View File
@@ -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가 키를 거부했거나 연결할 수 없습니다",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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 отклонил ключ или недоступен",
+2
View File
@@ -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",
+2
View File
@@ -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 拒绝了该密钥或无法访问",
+2
View File
@@ -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 拒絕該金鑰或無法連線",
+70
View File
@@ -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
+38 -28
View File
@@ -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:<id>` 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:<id>` 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/<tv id>` 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/<tv id>` 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.
@@ -0,0 +1,99 @@
<app-portal-detail-shell
[title]="title()"
[description]="overview()"
[posterUrl]="posterUrl()"
[backdropUrl]="backdropUrl()"
[playbackActive]="playbackActive()"
(closePlayerRequested)="closeInlinePlayback()"
>
<ng-template appDetailTags>
@if (year()) {
<span class="details__tag">{{ year() }}</span>
}
@if (genres()) {
<span class="details__tag">{{ genres() }}</span>
}
@if (runtimeLabel()) {
<span class="details__tag">{{ runtimeLabel() }}</span>
}
@if (rating()) {
<span class="details__tag details__tag--rating">
⭐ {{ rating() }}
</span>
}
</ng-template>
<ng-template appDetailMeta>
@if (cast().length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<div class="details__cast-strip">
@for (
member of cast();
track member.tmdbPersonId
? 'p' + member.tmdbPersonId
: 'i' + $index
) {
<span
class="details__cast-chip"
[title]="
member.character
? member.name + ' — ' + member.character
: member.name
"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
</div>
}
@if (directors()) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ directors() }}</span>
</div>
}
</ng-template>
<ng-template appDetailActions>
@if (canPlayInline() && !playbackActive()) {
<button
class="play-btn"
data-test-id="m3u-vod-play"
(click)="startPlayback()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
}
</ng-template>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[volume]="volume()"
[playerOverride]="playerOverride()"
(closed)="closeInlinePlayback()"
(externalFallbackRequested)="externalFallbackRequested.emit($event)"
/>
}
</app-portal-detail-shell>
@@ -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;
}
@@ -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: '<ng-content />',
})
class StubPortalDetailShellComponent {
readonly title = input<string>();
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
readonly playbackActive = input(false);
readonly backClicked = output<void>();
readonly closePlayerRequested = output<void>();
}
@Component({
selector: 'app-portal-inline-player',
standalone: true,
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
readonly closed = output<void>();
readonly externalFallbackRequested = output<unknown>();
}
const channel = (overrides: Partial<Channel> = {}): 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> = {}
): 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<M3uVodDetailComponentType>;
let enrichMovie: jest.Mock;
beforeAll(async () => {
({ M3uVodDetailComponent } =
await import('./m3u-vod-detail.component'));
});
const flush = async () => {
await new Promise<void>((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();
});
});
@@ -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<Channel>();
/** Parent-owned playback payload (headers/DRM already resolved). */
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly playbackSessionKey = input.required<string>();
/** 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<VideoPlayer | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/**
* 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<void>();
/** 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<ResolvedPortalPlayback | null>(() => {
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);
}
}
@@ -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 = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const flush = () => new Promise<void>((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,
});
});
});
@@ -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<Channel, 'id' | 'name'>): 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<M3uVodMetadataState>(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<Channel, 'id' | 'name'>): 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,
});
}
}
@@ -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<VideoPlayerComponentInstance>;
let component: VideoPlayerComponentInstance;
const playlistId = signal('playlist-1');
const activeChannel = signal<Channel | null>(null);
const activePlaybackUrl = signal<string | null>(null);
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal<EpgProgram | null>(null);
const activeEpgProgram = signal<EpgProgram | null>(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const stripCountryPrefix = signal(false);
const m3uVodDetails = signal<boolean | undefined>(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<string[]>([]),
},
},
{
provide: StorageMap,
useValue: {
get: jest.fn(() =>
of({
player: player(),
showCaptions: showCaptions(),
} as Partial<Settings>)
),
},
},
{
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);
});
});
});
@@ -64,113 +64,135 @@
</button>
}
@if (activeChannel(); as activeChannel) {
@if (
activeChannel.radio === 'true' ||
shouldShowInlinePlayer(activeChannel)
) {
<div class="video-player">
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
[channelName]="displayChannelName()"
[volume]="volume()"
(volumeChange)="onInlineVolumeChange($event)"
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
[playerOverride]="
activeChannelIsDash()
? dashPlayerOverride()
: (playerSettings.player ?? null)
"
[volume]="volume()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
/>
}
</div>
}
@if (supportsEpg && activeChannel.radio !== 'true') {
@let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel);
<div
class="epg"
[class.epg--inline]="canCollapseEpgPanel"
[class.epg--list]="epgViewMode() === 'list'"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
@if (showMovieDetail()) {
<app-m3u-vod-detail
[channel]="activeChannel"
[playback]="embeddedPlayback()"
[playbackSessionKey]="playbackSessionKey()"
[inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)"
[volume]="volume()"
[playerOverride]="playerSettings.player ?? null"
(playbackStarted)="refreshVolumeFromBus()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
>
<div class="epg-content">
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
/>
} @else {
@if (
activeChannel.radio === 'true' ||
shouldShowInlinePlayer(activeChannel)
) {
<div class="video-player">
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
[channelName]="displayChannelName()"
[volume]="volume()"
(volumeChange)="onInlineVolumeChange($event)"
/>
} @else {
<app-epg-timeline
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
[playerOverride]="
activeChannelIsDash()
? dashPlayerOverride()
: (playerSettings.player ?? null)
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
[volume]="volume()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
/>
}
</div>
</div>
}
@if (supportsEpg && activeChannel.radio !== 'true') {
@let canCollapseEpgPanel =
shouldShowInlinePlayer(activeChannel);
<div
class="epg"
[class.epg--inline]="canCollapseEpgPanel"
[class.epg--list]="epgViewMode() === 'list'"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
<div class="epg-content">
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="
liveEpgPanelSummaryLabelKey()
"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
/>
} @else {
<app-epg-timeline
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="
liveEpgPanelSummaryLabelKey()
"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
/>
}
</div>
</div>
}
}
@if (showChannelNumberOverlay) {
@@ -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
);
}
@@ -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(),
+1
View File
@@ -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';
@@ -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;
+1
View File
@@ -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';
@@ -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);
});
});
@@ -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/<user>/<pass>/<id>`,
* 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<Channel, 'url' | 'name' | 'radio'> | 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))
);
}
@@ -26,6 +26,8 @@ class StubWebPlayerViewComponent {
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
@@ -23,6 +23,8 @@ class StubWebPlayerViewComponent {
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
@@ -89,6 +89,8 @@
[title]="title()"
[mediaTitle]="playerMediaTitle()"
[playback]="playback()"
[volume]="volume()"
[playerOverride]="playerOverride()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
[alternativeSources]="alternativeSources()"
@@ -26,6 +26,8 @@ class StubWebPlayerViewComponent {
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
@@ -72,6 +72,20 @@ export class PortalInlinePlayerComponent {
readonly seriesTitle = input<string | null>(null);
/** "Up Next" entries built by the series host; null for movies/live. */
readonly upNextEpisodes = input<UpNextRailItem[] | null>(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<VideoPlayer | null>(null);
private readonly settingsStore = inject(SettingsStore);
// Strip only live-channel titles — VOD/series titles ("Mission:
// Impossible - Fallout") must never lose their leading segment.