mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(m3u): determine VOD playback independently of TMDB (#1594)
This commit is contained in:
1 parent
6e3f0d258f
commit
a417826b01
11 files changed
+410
-56
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
```
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+4
-2
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user