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

M3U entries recognized as movie files (container extension or an
Xtream-style /movie/ path) now swap the player + EPG zone for the portals'
two-state VOD detail shell, fed by TMDB metadata. Watch-first: activation
still plays immediately, with plot, cast, rating and artwork below the
player; Esc reveals the Browse hero. Gated on TMDB enrichment being enabled
plus a new default-on Settings.m3uVodDetails toggle in the TMDB settings
section. External MPV/VLC users keep the Browse layout; radio, DASH,
/series/ paths and episode-marker names keep today's live layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-12 00:20:57 +02:00
1 parent 6041233f41
commit 6ec681618d
43 files changed
+1788 -139

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):
```
@@ -1241,6 +1256,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) 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; a `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`. 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`
@@ -35,6 +35,7 @@ export function createSettingsForm(
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
m3uVodDetails: true,
...(supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
@@ -123,6 +124,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);
}
}
@@ -87,6 +87,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 拒絕該金鑰或無法連線",
+54
View File
@@ -1062,6 +1062,60 @@ 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); the host
overrides `isLive: false` (seek bar/VOD semantics) and stamps the resolved
title/poster into the player chrome.
- `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,
@@ -373,7 +380,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
@@ -429,27 +439,27 @@ dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
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
@@ -473,7 +483,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,97 @@
<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"
(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,261 @@
import { Component, input, output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
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 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;
}) => {
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
);
}
fixture.detectChanges();
await flush();
};
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 provider identity before TMDB answers', async () => {
await create({ channel: channel(), playback: playback() });
const inline = fixture.componentInstance.inlinePlayback();
expect(inline?.isLive).toBe(false);
expect(inline?.title).toBe('Dune (2021) 1080p');
expect(inline?.thumbnail).toBe('http://logo/dune.png');
});
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');
// The player chrome follows the resolved identity
expect(component.inlinePlayback()?.title).toBe('Dune');
});
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('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,168 @@
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 } 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);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/** 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(', ')
);
readonly inlinePlayback = computed<ResolvedPortalPlayback | null>(() => {
if (!this.inlinePlayerAvailable() || this.playerDismissed()) {
return null;
}
const playback = this.playback();
if (!playback) {
return null;
}
// The parent built this payload for a LIVE channel; the recognized
// movie plays with VOD semantics (seek bar, duration) and wears the
// resolved title/poster in the player chrome.
return {
...playback,
isLive: false,
title: this.title(),
thumbnail: this.posterUrl() ?? playback.thumbnail,
};
});
readonly playbackActive = computed(() => this.inlinePlayback() !== null);
readonly canPlayInline = computed(
() => this.inlinePlayerAvailable() && this.playback() !== null
);
closeInlinePlayback(): void {
this.playerDismissed.set(true);
}
startPlayback(): void {
this.playerDismissed.set(false);
}
}
@@ -0,0 +1,152 @@
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({
channelId: 'ch1',
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().channelId).toBe('ch2');
second.resolve({ id: 2, title: 'Alien' });
await flush();
expect(service.state()).toEqual({
channelId: 'ch2',
status: 'matched',
details: { id: 2, title: 'Alien' },
});
});
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({
channelId: null,
status: 'idle',
details: null,
});
});
});
@@ -0,0 +1,82 @@
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 {
/** Channel the current state belongs to — staleness anchor */
channelId: string | null;
status: M3uVodMetadataStatus;
details: TmdbMovieDetails | null;
}
const IDLE_STATE: M3uVodMetadataState = {
channelId: null,
status: 'idle',
details: null,
};
/**
* 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 channelId = channel.id;
if (this.stateSignal().channelId === channelId) {
return;
}
if (!this.tmdb.isEnabled()) {
this.stateSignal.set({ channelId, status: 'none', details: null });
return;
}
this.stateSignal.set({ channelId, 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(channelId, details),
() => this.settle(channelId, null)
);
}
reset(): void {
this.stateSignal.set(IDLE_STATE);
}
private settle(channelId: string, details: TmdbMovieDetails | null): void {
if (this.stateSignal().channelId !== channelId) {
return;
}
this.stateSignal.set({
channelId,
status: details ? 'matched' : 'none',
details,
});
}
}
@@ -0,0 +1,314 @@
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);
});
});
@@ -64,113 +64,132 @@
</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)"
(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) {
@@ -29,6 +29,7 @@ import {
extractDrmFromRaw,
isDashChannel,
isDashStreamUrl,
isLikelyM3uMovie,
isM3uCatchupPlaybackSupported,
} from '@iptvnator/shared/m3u-utils';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
@@ -103,6 +104,7 @@ import {
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
Channel,
@@ -119,6 +121,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';
@@ -137,6 +140,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
CommonModule,
EpgListViewComponent,
EpgTimelineComponent,
M3uVodDetailComponent,
MatButtonModule,
MatIconModule,
MatTooltipModule,
@@ -162,6 +166,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 +217,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[],
@@ -599,7 +620,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 +690,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
);
@@ -1142,7 +1168,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 +1180,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';
@@ -125,6 +125,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))
);
}