fix(m3u): determine VOD playback independently of TMDB (#1594)

This commit is contained in:
4gray authored and GitHub committed 2026-09-12 22:47:54 +02:00
1 parent 6e3f0d258f
commit a417826b01
11 files changed
+410 -56

No files matched your search

+6
View File
@@ -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.
+13
View File
@@ -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
+11 -1
View File
@@ -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):
```
+182 -26
View File
@@ -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<void> {
});
}
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> {
async function selectPlayer(
page: Page,
player = 'HTML5 video player'
): 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);
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<void> {
@@ -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<void> {
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<void> {
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 <video>; hover the surface.
await view.hover();
const controls = view.locator('app-player-controls');
await controls.getByRole('button', { name: 'Pause', exact: true }).click();
const position = controls.getByRole('slider', {
name: 'Playback position',
exact: true,
});
await expect(position).toBeEnabled();
// Native keyboard interaction commits a real seek; never assign currentTime.
await position.focus();
await position.press('Home');
await position.press('ArrowRight');
await position.press('ArrowRight');
await expect
.poll(() => video.evaluate((el: HTMLVideoElement) => el.currentTime))
.toBeCloseTo(2, 0);
await controls.getByRole('button', { name: 'Play', exact: true }).click();
await expect
.poll(() => video.evaluate((el: HTMLVideoElement) => el.currentTime))
.toBeGreaterThan(2.5);
}
type MetadataMode = 'disabled' | 'details-disabled' | 'enabled';
async function configureMetadata(
page: Page,
mode: MetadataMode
): Promise<void> {
if (mode === 'disabled') return;
await enableTmdb(page);
if (mode === 'details-disabled') {
await page
.locator('[data-test-id="tmdb-m3u-vod-details"] input')
.uncheck();
await saveSettings(page);
}
}
for (const [player, selector] of [
['HTML5 video player', 'app-html-video-player'],
['Video.js player', 'app-vjs-player'],
['ArtPlayer', 'app-art-player'],
]) {
for (const metadata of [
'disabled',
'details-disabled',
'enabled',
] as const) {
test(`@web @m3u VOD seek without metadata coupling (${player}, ${metadata})`, async ({
page,
}) => {
test.setTimeout(90_000);
await serveSeekableClip(page);
await serveTmdb(page);
await configureMetadata(page, metadata);
await selectPlayer(page, player);
await importPlaylist(
page,
[
MOVIE_PLAYLIST,
'#EXTINF:-1 group-title="Series",Example Show S01E02',
`${FIXTURE_HOST}/series/episode.mp4`,
].join('\n'),
3
);
const view = page.locator('app-web-player-view');
await sidebarEntry(page, 'Live One').click();
await expect(view.locator(selector)).toHaveCount(1);
await expect(
view.getByRole('slider', {
name: 'Playback position',
exact: true,
})
).toHaveCount(0);
await expect(
view.getByLabel('Live stream', { exact: true })
).toBeVisible();
await sidebarEntry(page, 'Dune (2021) 1080p').click();
await expect(detail(page)).toHaveCount(
metadata === 'enabled' ? 1 : 0
);
await expect(view.locator(selector)).toHaveCount(1);
await seekUsingControls(page);
await sidebarEntry(page, 'Example Show S01E02').click();
await expect(detail(page)).toHaveCount(0);
await expect(view.locator(selector)).toHaveCount(1);
await seekUsingControls(page);
await sidebarEntry(page, 'Live One').click();
await expect(view.locator(selector)).toHaveCount(1);
await expect(
view.getByRole('slider', {
name: 'Playback position',
exact: true,
})
).toHaveCount(0);
await expect(
view.getByLabel('Live stream', { exact: true })
).toBeVisible();
});
}
}
test('@web @m3u @tmdb recognized movies open the VOD detail view', async ({
page,
}) => {
await serveTmdb(page);
await serveStreams(page);
await selectHtml5Player(page);
await serveSeekableClip(page);
await selectPlayer(page);
await enableTmdb(page);
await importPlaylist(page);
@@ -210,8 +366,8 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
page,
}) => {
await serveTmdb(page);
await serveStreams(page);
await selectHtml5Player(page);
await serveSeekableClip(page);
await selectPlayer(page);
await enableTmdb(page);
await importPlaylist(page);
@@ -267,8 +423,8 @@ for (const theme of ['light', 'dark']) {
test(`@web @m3u channel scrolling keeps focus after selection (${theme})`, async ({
page,
}) => {
await serveStreams(page);
await selectHtml5Player(page);
await serveSeekableClip(page);
await selectPlayer(page);
const channels = Array.from(
{ length: 60 },
(_, index) =>
+30 -3
View File
@@ -1411,6 +1411,31 @@ bypass Playwright interception) and
`electron-backend-e2e:src/dash-clearkey.e2e.ts` (real ClearKey EME in
Electron).
## M3U Playback Mode
`isLikelyM3uVod()` in `libs/shared/m3u-utils` determines playback mode
independently of TMDB, `Settings.m3uVodDetails`, and the entry's name.
Video-file extensions (`mp4`, `mkv`, `webm`, and the existing movie-container
list) or exact `/movie/`, `/movies/`, `/vod/`, `/series/` pathname segments
identify VOD. Extension parsing honors the existing query-declared formats.
Episodes receive VOD controls even when movie metadata recognition excludes
their names. A group label or episode name alone is not playback evidence.
Radio and DASH retain their existing paths; ordinary HLS/TS and unknown URLs
without a VOD path retain live mode. No network probing is performed.
`VideoPlayerComponent.embeddedPlayback()` is the mode owner for both the
ordinary M3U player and the movie detail host: a catch-up URL or positive VOD
evidence sets `isLive: false`. HTML5, Video.js, ArtPlayer and Embedded MPV
consume that existing flag without provider-specific changes. Actual seeking
still requires a usable duration and a seekable source. External MPV/VLC
launch payloads, session identity, headers, DRM and radio playback are unchanged.
Xtream and Stalker continue to determine their own playback modes.
Coverage includes the detection utility and M3U host specs, plus real seek
controls in `apps/web-e2e/src/m3u-movie-details.e2e.ts` using the local WebM
fixture with HTTP Range responses. The E2E matrix covers all three web players,
TMDB off, movie details off, details on, episodes and live/VOD transitions.
## Movie Recognition (VOD Detail View)
M3U playlists routinely mix live channels with movie FILES ("Movies"/"VOD"
@@ -1447,8 +1472,9 @@ Known accepted cost: "Star Wars: Episode 1" is skipped.
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
persisted `volume()`; the host forwards the same payload, whose `isLive`
is already determined independently of metadata by the M3U parent. 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
@@ -1477,7 +1503,8 @@ releaseTagYear(channel.name) })` — the resolver already normalizes titles
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
**Out of scope (v1)**: series episode metadata (movie detection skips them;
episode playback still receives VOD controls when its URL qualifies), playback
position persistence for M3U movies, clickable cast chips (no
`actor/:personId` route in the M3U tree), and downloads.
@@ -76,7 +76,7 @@ const playback = (
streamUrl: 'http://host/movie/user/pass/1.mkv',
title: 'Dune (2021) 1080p',
thumbnail: 'http://logo/dune.png',
isLive: true,
isLive: false,
...overrides,
}) as ResolvedPortalPlayback;
@@ -171,9 +171,11 @@ describe('M3uVodDetailComponent', () => {
});
it('plays with VOD semantics and the parent payload', async () => {
await create({ channel: channel(), playback: playback() });
const parentPlayback = playback();
await create({ channel: channel(), playback: parentPlayback });
const inline = fixture.componentInstance.inlinePlayback();
expect(inline).toBe(parentPlayback);
expect(inline?.isLive).toBe(false);
expect(inline?.streamUrl).toBe('http://host/movie/user/pass/1.mkv');
expect(inline?.title).toBe('Dune (2021) 1080p');
@@ -164,15 +164,13 @@ export class M3uVodDetailComponent {
* 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).
* The parent already determines VOD playback independently of metadata.
*/
readonly inlinePlayback = computed<ResolvedPortalPlayback | null>(() => {
if (!this.inlinePlayerAvailable() || this.playerDismissed()) {
return null;
}
const playback = this.playback();
return playback ? { ...playback, isLive: false } : null;
return this.playback();
});
readonly playbackActive = computed(() => this.inlinePlayback() !== null);
readonly canPlayInline = computed(
@@ -310,6 +310,82 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => {
expect(component.showMovieDetail()).toBe(true);
});
describe('playback mode is independent of movie metadata', () => {
it.each([
[false, true],
[true, false],
[false, false],
[true, true],
])(
'plays files as VOD with TMDB=%s and details=%s',
(tmdb, details) => {
tmdbEnabled.set(tmdb);
m3uVodDetails.set(details);
syncStoreState({
...movieChannel,
url: 'http://host/dune.mp4',
});
fixture.detectChanges();
expect(component.embeddedPlayback()?.isLive).toBe(false);
expect(component.showMovieDetail()).toBe(tmdb && details);
}
);
it.each([true, false])('plays episodes as VOD with TMDB=%s', (tmdb) => {
tmdbEnabled.set(tmdb);
for (const url of [
'http://host/episode.mkv',
'http://host/series/123',
]) {
syncStoreState({ ...movieChannel, name: 'Show S01E02', url });
fixture.detectChanges();
expect(component.embeddedPlayback()?.isLive).toBe(false);
expect(component.showMovieDetail()).toBe(false);
}
});
it('updates mode when switching live → movie → episode → live', () => {
tmdbEnabled.set(false);
for (const [channel, isLive] of [
[liveChannel, true],
[movieChannel, false],
[{ ...movieChannel, url: 'http://host/series/123' }, false],
[liveChannel, true],
] as const) {
syncStoreState(channel);
fixture.detectChanges();
expect(component.embeddedPlayback()?.isLive).toBe(isLive);
}
});
it('keeps catch-up non-live and restores live on return', () => {
syncStoreState(liveChannel);
activePlaybackUrl.set('http://host/archive.m3u8');
fixture.detectChanges();
expect(component.embeddedPlayback()?.isLive).toBe(false);
expect(component.embeddedPlayback()?.streamUrl).toBe(
'http://host/archive.m3u8'
);
activePlaybackUrl.set(null);
fixture.detectChanges();
expect(component.embeddedPlayback()?.isLive).toBe(true);
});
it('does not rebuild the playback payload when metadata settings change', () => {
syncStoreState(movieChannel);
fixture.detectChanges();
const playback = component.embeddedPlayback();
tmdbEnabled.set(false);
m3uVodDetails.set(false);
fixture.detectChanges();
expect(component.embeddedPlayback()).toBe(playback);
});
});
describe('volume handed to each new player', () => {
afterEach(() => localStorage.removeItem('volume'));
@@ -31,6 +31,7 @@ import {
isDashChannel,
isDashStreamUrl,
isLikelyM3uMovie,
isLikelyM3uVod,
isM3uCatchupPlaybackSupported,
resolveM3uCatchupUrl,
} from '@iptvnator/shared/m3u-utils';
@@ -515,7 +516,7 @@ export class VideoPlayerComponent
activeChannel.tvg?.name ||
playbackTarget.url,
thumbnail: activeChannel.tvg?.logo ?? null,
isLive: !this.activePlaybackUrl(),
isLive: !this.activePlaybackUrl() && !isLikelyM3uVod(activeChannel),
headers: Object.keys(headers).length > 0 ? headers : undefined,
userAgent: effective['user-agent'],
referer: effective.referer,
@@ -1,4 +1,8 @@
import { hasEpisodeMarker, isLikelyM3uMovie } from './m3u-vod-detection.util';
import {
hasEpisodeMarker,
isLikelyM3uMovie,
isLikelyM3uVod,
} from './m3u-vod-detection.util';
const movie = (url: string, name = 'Some Movie', radio = '') => ({
url,
@@ -6,6 +10,59 @@ const movie = (url: string, name = 'Some Movie', radio = '') => ({
radio,
});
describe('isLikelyM3uVod', () => {
it.each([
'http://host/movie.mp4',
'http://host/episode.MKV?token=test',
'http://host/file.webm',
'http://host/play?ext=mp4',
'http://host/play?format=webm',
'http://host/movie/123',
'http://host/movies/123.m3u8',
'http://host/vod/123.ts',
'http://host/series/123',
'http://host/SERIES/123.m3u8',
])('recognizes %s regardless of an episode marker', (url) => {
expect(isLikelyM3uVod(movie(url))).toBe(true);
expect(isLikelyM3uVod(movie(url, 'Show S01E02'))).toBe(true);
expect(isLikelyM3uVod(movie(url, 'Шоу 2 серия'))).toBe(true);
});
it.each([
'http://host/live.m3u8',
'http://host/live.ts',
'http://host/live.m4s',
'http://host/live',
'http://host/audio.mp3',
'http://host/movie/123.mpd',
'http://host/series/123.mpd',
'http://host/series/123?format=mpd',
'http://host/moviestar/live',
'http://host/series-news/live',
'http://series/live',
'http://host/live?redirect=/series/123',
'http://host/live#/movie/123',
'http://[',
'',
])('preserves the existing mode for %s even with a series name', (url) => {
expect(isLikelyM3uVod(movie(url, 'Show S01E02'))).toBe(false);
});
it('excludes radio and missing channels', () => {
expect(
isLikelyM3uVod(movie('http://host/file.mp4', 'Radio', 'true'))
).toBe(false);
expect(isLikelyM3uVod(null)).toBe(false);
expect(isLikelyM3uVod(undefined)).toBe(false);
});
it('keeps movie metadata detection narrower than VOD playback', () => {
const episode = movie('http://host/series/123.mp4');
expect(isLikelyM3uVod(episode)).toBe(true);
expect(isLikelyM3uMovie(episode)).toBe(false);
});
});
describe('isLikelyM3uMovie', () => {
describe('movie container extensions', () => {
it.each([
@@ -18,12 +18,12 @@ import { getPlaybackMediaExtensionFromUrl } from './playback-media-extension.uti
*/
/**
* File containers that only ever carry finished assets. Streaming
* File containers used as evidence of 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([
const VOD_CONTAINER_EXTENSIONS = new Set([
'avi',
'asf',
'divx',
@@ -46,8 +46,8 @@ const MOVIE_CONTAINER_EXTENSIONS = new Set([
* 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.
* `/series/` identifies episode playback but remains excluded from movie
* metadata recognition.
*/
const MOVIE_PATH_SEGMENTS = new Set(['movie', 'movies', 'vod']);
const SERIES_PATH_SEGMENT = 'series';
@@ -58,7 +58,7 @@ const SERIES_PATH_SEGMENT = 'series';
* 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.
* current layout. Playback mode is determined separately from metadata.
*
* `(?:^|[^\p{L}])` guards instead of `\b`: JS word boundaries are
* ASCII-only and never fire next to Cyrillic letters.
@@ -104,25 +104,33 @@ function pathSegmentsOf(url: string): string[] {
}
}
export function isLikelyM3uMovie(
channel: Pick<Channel, 'url' | 'name' | 'radio'> | null | undefined
/** Playback evidence only: independent of titles and TMDB preferences. */
export function isLikelyM3uVod(
channel: Pick<Channel, 'url' | '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))
VOD_CONTAINER_EXTENSIONS.has(getPlaybackMediaExtensionFromUrl(url)) ||
pathSegmentsOf(url).some(
(segment) =>
MOVIE_PATH_SEGMENTS.has(segment) ||
segment === SERIES_PATH_SEGMENT
)
);
}
/** Movie-only metadata gate; episodes still receive VOD playback controls. */
export function isLikelyM3uMovie(
channel: Pick<Channel, 'url' | 'name' | 'radio'> | null | undefined
): boolean {
return (
!!channel &&
isLikelyM3uVod(channel) &&
!hasEpisodeMarker(channel.name) &&
!pathSegmentsOf(channel.url).includes(SERIES_PATH_SEGMENT)
);
}