mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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:
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,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))
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user