From a417826b015fd1220d02511c015f1d76bf9169ff Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:47:54 +0200 Subject: [PATCH] fix(m3u): determine VOD playback independently of TMDB (#1594) --- .changes/m3u-vod-playback-mode.md | 6 + AGENTS.md | 13 ++ CLAUDE.md | 12 +- apps/web-e2e/src/m3u-movie-details.e2e.ts | 208 +++++++++++++++--- docs/architecture/m3u-playlist-module.md | 33 ++- .../m3u-vod-detail.component.spec.ts | 6 +- .../m3u-vod-detail.component.ts | 6 +- .../video-player-movie-gate.spec.ts | 76 +++++++ .../video-player/video-player.component.ts | 3 +- .../src/lib/m3u-vod-detection.util.spec.ts | 59 ++++- .../src/lib/m3u-vod-detection.util.ts | 44 ++-- 11 files changed, 410 insertions(+), 56 deletions(-) create mode 100644 .changes/m3u-vod-playback-mode.md diff --git a/.changes/m3u-vod-playback-mode.md b/.changes/m3u-vod-playback-mode.md new file mode 100644 index 000000000..3bfe2dd46 --- /dev/null +++ b/.changes/m3u-vod-playback-mode.md @@ -0,0 +1,6 @@ +--- +type: fix +area: m3u +--- + +Movies and episodes recognized as video files in M3U playlists now offer playback time and seeking in built-in players, even when TMDB or movie details are disabled. Seeking remains dependent on the source's support. diff --git a/AGENTS.md b/AGENTS.md index 6cce0d3ad..e3a5ac38a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,6 +404,19 @@ Key files: - `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video - `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface +## M3U Playback Mode + +`isLikelyM3uVod` in `libs/shared/m3u-utils` recognizes video-file extensions +and exact `/movie/`, `/movies/`, `/vod/`, `/series/` URL pathname segments, +independently of TMDB and `Settings.m3uVodDetails`. The M3U host's +`embeddedPlayback()` sets `isLive: false` for those entries or a catch-up URL; +the movie detail host forwards the same payload. Movie metadata recognition +still excludes episodes. Ordinary HLS/TS and unknown URLs without VOD evidence, +DASH and radio retain their existing behavior. Actual seeking requires source +support. Xtream/Stalker, external MPV/VLC payloads and session identity are +unchanged. Contract: `docs/architecture/m3u-playlist-module.md` +(M3U Playback Mode). + ## M3U URL User-Agent - `PlaylistsService.getPlaylist()` joins the per-playlist mutation queue so a diff --git a/CLAUDE.md b/CLAUDE.md index e527f9e96..686ac4611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -569,10 +569,20 @@ 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 +parent's unchanged `embeddedPlayback()` payload); external MPV/VLC users keep Browse. See "Movie Recognition (VOD Detail View)" in `docs/architecture/m3u-playlist-module.md`. +M3U playback mode is independent of this metadata gate: `isLikelyM3uVod` +recognizes video-file extensions and exact `/movie|movies|vod|series/` URL +segments, including episodes. The M3U parent's `embeddedPlayback()` sets +`isLive: false` for those entries or a catch-up URL, even with TMDB/details +disabled; the detail host forwards that same payload. Ordinary HLS/TS and +unknown URLs without VOD evidence, DASH and radio retain their existing +behavior. Seeking requires a seekable source and duration. Xtream/Stalker, +external MPV/VLC launch payloads and session identity are unchanged. Contract: +`docs/architecture/m3u-playlist-module.md` (M3U Playback Mode). + Channel List Component Structure (parent coordinator pattern): ``` diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 4859d22c4..9ce0cb863 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -1,4 +1,6 @@ import type { Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { expect, test } from './fixtures'; import { waitForScrollIdle } from './e2e-helpers'; @@ -8,10 +10,8 @@ import { waitForScrollIdle } from './e2e-helpers'; * 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. + * Playback cases decode a local clip; mode regressions seek through the + * real player controls without depending on a remote media source. */ const FIXTURE_HOST = 'https://m3u-movie-fixture.local'; @@ -76,29 +76,26 @@ async function serveTmdb(page: Page): Promise { }); } -async function serveStreams(page: Page): Promise { - await page.route(`${FIXTURE_HOST}/**`, (route) => - route.fulfill({ - status: 200, - contentType: 'video/mp4', - body: Buffer.alloc(0), - }) - ); -} - async function saveSettings(page: Page): Promise { const saveButton = page.getByRole('button', { name: 'Save changes' }); await saveButton.click(); await expect(saveButton).toBeHidden(); } -async function selectHtml5Player(page: Page): Promise { +async function selectPlayer( + page: Page, + player = 'HTML5 video player' +): Promise { await page.goto('/workspace/settings/playback'); - await page.locator('[data-test-id="select-video-player"]').click(); - await page - .getByRole('option', { name: 'HTML5 video player', exact: true }) - .click(); - await saveSettings(page); + const select = page.locator('[data-test-id="select-video-player"]'); + await expect(select).toBeVisible(); + const previous = await select.innerText(); + await select.click(); + await page.getByRole('option', { name: player, exact: true }).click(); + await expect(select).toContainText(player); + if (!previous.includes(player)) { + await saveSettings(page); + } } async function enableTmdb(page: Page): Promise { @@ -139,12 +136,171 @@ const inlineVideo = (page: Page) => const sidebarEntry = (page: Page, name: string) => page.locator('[data-test-id="channel-item"]').filter({ hasText: name }); +async function serveSeekableClip(page: Page): Promise { + const clip = readFileSync( + join(__dirname, 'fixtures/playback/episode.webm') + ); + await page.route(`${FIXTURE_HOST}/**`, async (route) => { + if (new URL(route.request().url()).pathname === '/live.m3u8') { + // A failed live manifest triggers engine recovery. Keep it loading + // while checking the host's controls, independently of that policy. + await page.waitForEvent('close', { timeout: 0 }); + return; + } + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = clip.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, clip.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'content-type': 'video/webm', + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + ...(range + ? { + 'content-range': `bytes ${start}-${end}/${clip.length}`, + } + : {}), + }, + body: clip.subarray(start, end + 1), + }); + }); +} + +async function seekUsingControls(page: Page): Promise { + const view = page.locator('app-web-player-view'); + const video = view.locator('video'); + await expect + .poll(() => + video.evaluate( + (el: HTMLVideoElement) => + Number.isFinite(el.duration) && + el.duration > 5 && + !el.paused + ) + ) + .toBe(true); + // ArtPlayer has an interaction layer above