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

M3U entries recognized as movie files now open in the portals' two-state VOD
detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first:
activation still plays immediately, with plot, cast, rating and artwork below
the player; Escape reveals the Browse hero.

Recognition is a synchronous URL-shape heuristic (movie container extension or
an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker
names keep today's live layout), gated on TMDB enrichment plus the new
default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA.

Review follow-ups included: the playback payload no longer carries TMDB fields
(its identity is the player's source-application key), the persisted volume
reaches the player and survives Browse → Play, the enrichment guard keys on
the full lookup identity, and the saved engine mounts first time instead of
briefly falling back to Video.js.

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

No files matched your search

@@ -131,6 +131,14 @@ export interface Settings {
* session, so this cannot loop.
*/
vodAutoFailover?: boolean;
/**
* Present M3U entries recognized as movie files (by URL shape) in the
* VOD detail view with TMDB metadata instead of the EPG zone. On by
* default; missing values mean enabled. Only takes effect while TMDB
* enrichment itself is enabled — without it there is no metadata to
* show, so recognition never runs.
*/
m3uVodDetails?: boolean;
epgUrl: string[];
streamFormat: StreamFormat;
openStreamOnDoubleClick: boolean;
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/dash.utils';
export * from './lib/kodiprop.utils';
export * from './lib/m3u-vod-detection.util';
export * from './lib/playback-media-extension.util';
export * from './lib/playlist.utils';
export * from './lib/catchup.utils';
@@ -0,0 +1,157 @@
import { hasEpisodeMarker, isLikelyM3uMovie } from './m3u-vod-detection.util';
const movie = (url: string, name = 'Some Movie', radio = '') => ({
url,
name,
radio,
});
describe('isLikelyM3uMovie', () => {
describe('movie container extensions', () => {
it.each([
'http://host/films/Dune.Part.Two.2024.mkv',
'http://host/films/Dune.mp4',
'http://host/films/OLDBOY.MKV',
'http://host/films/movie.avi',
'http://host/films/movie.webm',
'http://host/films/clip.m4v',
])('recognizes %s', (url) => {
expect(isLikelyM3uMovie(movie(url))).toBe(true);
});
it('recognizes a query-declared extension', () => {
expect(isLikelyM3uMovie(movie('http://host/stream?ext=mp4'))).toBe(
true
);
});
it.each([
'http://host/live/channel.ts',
'http://host/live/channel.m3u8',
'http://host/live/segment.m4s',
'http://host/live/audio.mp3',
'http://host/live/audio.aac',
'http://host/live/channel',
])('does not treat streaming/audio URL %s as a movie', (url) => {
expect(isLikelyM3uMovie(movie(url))).toBe(false);
});
});
describe('xtream-style path segments', () => {
it('recognizes /movie/ paths without a file extension', () => {
expect(
isLikelyM3uMovie(movie('http://host/movie/user/pass/12345'))
).toBe(true);
});
it('recognizes HLS VOD under /movie/', () => {
expect(
isLikelyM3uMovie(
movie('http://host/movie/user/pass/12345.m3u8')
)
).toBe(true);
});
it.each(['movies', 'vod'])('recognizes /%s/ segment', (segment) => {
expect(
isLikelyM3uMovie(movie(`http://host/${segment}/12345`))
).toBe(true);
});
it('is case-insensitive about the segment', () => {
expect(
isLikelyM3uMovie(movie('http://host/MOVIE/user/pass/1'))
).toBe(true);
});
it('does not fire on a segment merely containing the word', () => {
expect(
isLikelyM3uMovie(movie('http://host/moviestar-tv/live'))
).toBe(false);
});
it('treats /series/ as episodes, not movies, even with a movie container', () => {
expect(
isLikelyM3uMovie(movie('http://host/series/user/pass/777.mkv'))
).toBe(false);
});
});
describe('exclusions', () => {
it('never treats a radio entry as a movie', () => {
expect(
isLikelyM3uMovie(movie('http://host/x.mp4', 'Jazz FM', 'true'))
).toBe(false);
});
it('never treats a DASH URL as a movie, even under /movie/', () => {
expect(
isLikelyM3uMovie(movie('http://host/movie/stream.mpd'))
).toBe(false);
});
it('skips entries whose name carries an episode marker', () => {
expect(
isLikelyM3uMovie(
movie('http://host/f/x.mkv', 'Breaking Bad S01E02')
)
).toBe(false);
});
it('handles null and missing urls', () => {
expect(isLikelyM3uMovie(null)).toBe(false);
expect(isLikelyM3uMovie(undefined)).toBe(false);
expect(isLikelyM3uMovie(movie(''))).toBe(false);
});
it('handles unparseable urls without throwing', () => {
expect(isLikelyM3uMovie(movie('http://['))).toBe(false);
});
});
});
describe('hasEpisodeMarker', () => {
it.each([
'Breaking Bad S01E02',
'Breaking Bad s1.e2',
'Breaking Bad S01 E02',
'Breaking Bad 1x02',
'The Boys Season 3',
'The Boys season_02',
'Sherlock Episode 5',
'Dark Folge 3',
'La Casa de Papel Episodio 2',
'Шерлок 2 серия',
'Во все тяжкие 1 сезон',
'Пацаны 3-й сезон',
'Эпизод 4 Шерлок',
])('detects "%s"', (name) => {
expect(hasEpisodeMarker(name)).toBe(true);
});
it.each([
'Dune Part Two',
'4x4',
'2001: A Space Odyssey',
'1917',
'Se7en',
'Postseason Story',
'The Fifth Season',
'Друзья Оушена 13',
'',
])('does not fire on "%s"', (name) => {
expect(hasEpisodeMarker(name)).toBe(false);
});
// Documented cost of the conservative gate: a real film whose NAME
// carries an episode word is skipped and keeps the current live-style
// view. Safe direction — never mis-enrich a series episode as a film.
it('skips "Star Wars: Episode 1" by design', () => {
expect(hasEpisodeMarker('Star Wars: Episode 1')).toBe(true);
});
it('handles null/undefined', () => {
expect(hasEpisodeMarker(null)).toBe(false);
expect(hasEpisodeMarker(undefined)).toBe(false);
});
});
@@ -0,0 +1,128 @@
import {
Channel,
SEASON_WORD_ALTERNATIVES,
} from '@iptvnator/shared/interfaces';
import { isDashStreamUrl } from './dash.utils';
import { getPlaybackMediaExtensionFromUrl } from './playback-media-extension.util';
/**
* Heuristic that decides whether an M3U entry is a movie FILE rather than a
* live stream, so the player can present the VOD detail experience (TMDB
* metadata instead of the EPG zone).
*
* The decision must be synchronous — the layout is chosen the moment a
* channel is activated, before any TMDB request — so it reads only what the
* playlist itself states: the URL shape and the entry name. It is
* deliberately conservative: a false negative keeps today's behavior, while
* a false positive would swap the EPG zone away from a live channel.
*/
/**
* File containers that only ever carry finished assets. Streaming
* packagings are deliberately absent: `.ts`/`.m3u8`/`.m4s` are how LIVE
* channels are delivered, and `.mpd` (DASH) has its own routing path.
* Audio extensions are absent too — an audio file is not a movie.
*/
const MOVIE_CONTAINER_EXTENSIONS = new Set([
'avi',
'asf',
'divx',
'flv',
'm2ts',
'm4v',
'mkv',
'mov',
'mp4',
'mpeg',
'mpg',
'rm',
'rmvb',
'vob',
'webm',
'wmv',
]);
/**
* Xtream-derived M3U exports address VOD as `/movie/<user>/<pass>/<id>`,
* often without a file extension. The segment is a strong VOD signal on its
* own, whatever the container (some panels serve HLS VOD under it).
* `/series/` is the same panels' EPISODE namespace — those are series
* content, which v1 does not recognize.
*/
const MOVIE_PATH_SEGMENTS = new Set(['movie', 'movies', 'vod']);
const SERIES_PATH_SEGMENT = 'series';
/**
* Episode markers in the entry name: "S01E01", "s1.e2", "1x02",
* "Episode 5", "2 серия", "Season 3" (word list shared with the season
* stripper in title-normalization). A marked entry is a series episode, not
* a movie — v1 skips those rather than mis-enriching them as films.
* Known cost: "Star Wars: Episode 1" is skipped too; that only keeps the
* current live-style view, which is the safe direction.
*
* `(?:^|[^\p{L}])` guards instead of `\b`: JS word boundaries are
* ASCII-only and never fire next to Cyrillic letters.
*/
const EPISODE_WORD_ALTERNATIVES =
'episode|episodio|folge|серия|эпизод|bölüm|odcinek|aflevering';
const SEASON_OR_EPISODE_WORD = `(?:${SEASON_WORD_ALTERNATIVES}|${EPISODE_WORD_ALTERNATIVES})`;
const SEASON_EPISODE_CODE =
/(?:^|[^a-z0-9])s\d{1,2}[\s._-]*e\d{1,3}(?![a-z0-9])/i;
// Requires a 2–3 digit episode part, so short film titles like "4x4" survive
const CROSS_EPISODE_CODE = /(?:^|[^0-9])\d{1,2}x\d{2,3}(?![0-9])/;
const WORD_FIRST_MARKER = new RegExp(
`(?:^|[^\\p{L}])${SEASON_OR_EPISODE_WORD}[\\s._-]*\\d{1,3}(?!\\d)`,
'iu'
);
const NUMBER_FIRST_MARKER = new RegExp(
`(?:^|[^\\p{L}\\d])\\d{1,2}[\\s._-]*(?:st|nd|rd|th|й|и|я|ой|ои)?[\\s._-]*${SEASON_OR_EPISODE_WORD}(?:$|[^\\p{L}])`,
'iu'
);
export function hasEpisodeMarker(name: string | null | undefined): boolean {
if (!name) {
return false;
}
return (
SEASON_EPISODE_CODE.test(name) ||
CROSS_EPISODE_CODE.test(name) ||
WORD_FIRST_MARKER.test(name) ||
NUMBER_FIRST_MARKER.test(name)
);
}
function pathSegmentsOf(url: string): string[] {
try {
return new URL(url, 'http://iptvnator.local').pathname
.split('/')
.filter((segment) => segment !== '')
.map((segment) => segment.toLowerCase());
} catch {
return [];
}
}
export function isLikelyM3uMovie(
channel: Pick<Channel, 'url' | 'name' | 'radio'> | null | undefined
): boolean {
const url = channel?.url;
if (!url || channel.radio === 'true' || isDashStreamUrl(url)) {
return false;
}
if (hasEpisodeMarker(channel.name)) {
return false;
}
const segments = pathSegmentsOf(url);
if (segments.includes(SERIES_PATH_SEGMENT)) {
return false;
}
return (
MOVIE_CONTAINER_EXTENSIONS.has(getPlaybackMediaExtensionFromUrl(url)) ||
segments.some((segment) => MOVIE_PATH_SEGMENTS.has(segment))
);
}