Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka

# Conflicts:
#	CLAUDE.md
#	libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
4gray committed 2026-07-24 18:44:22 +02:00
commit 5b2dd3b8b0
425 files changed
+59216 -3432

No files matched your search

@@ -1,14 +1,16 @@
import { inject, Injectable } from '@angular/core';
import {
ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES,
ElectronBridgeApi,
ElectronBridgeEpgFetchResult,
ElectronBridgeEpgMapping,
ElectronBridgeEpgProgress,
ElectronBridgeEpgProgressStats,
ElectronBridgeEpgProgressStatus,
ElectronBridgeEpgSearchResult,
ElectronBridgeEpgChannelWithPrograms,
ElectronBridgeEpgFreshnessResult,
ElectronBridgeEpgLookupOptions,
ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES,
ElectronBridgeResult,
ElectronBridgeTrustOptions,
EpgChannelMetadata,
@@ -38,6 +40,11 @@ type EpgElectronBridge = Pick<
| 'getEpgChannelsByRange'
| 'onEpgProgress'
| 'searchEpgPrograms'
| 'getEpgMapping'
| 'getEpgMappingsBatch'
| 'setEpgMapping'
| 'deleteEpgMapping'
| 'searchEpgChannels'
>;
@Injectable({ providedIn: 'root' })
@@ -80,6 +87,10 @@ export class EpgRuntimeBridgeService {
return this.runtime.supportsEpgProgramSearch;
}
get supportsEpgMapping(): boolean {
return this.runtime.supportsEpgMapping;
}
fetchEpg(
urls: string[],
options?: ElectronBridgeTrustOptions
@@ -221,6 +232,66 @@ export class EpgRuntimeBridgeService {
);
}
getEpgMapping(
channelKey: string
): Promise<ElectronBridgeEpgMapping | null> {
if (!this.supportsEpgMapping) {
return Promise.resolve(null);
}
return this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null);
}
getEpgMappingsBatch(
channelKeys: string[]
): Promise<Record<string, string> | null> {
if (!this.supportsEpgMapping || channelKeys.length === 0) {
return Promise.resolve(null);
}
return (
this.bridge?.getEpgMappingsBatch?.(channelKeys) ??
Promise.resolve(null)
);
}
setEpgMapping(
channelKey: string,
epgChannelId: string,
playlistId?: string
): Promise<ElectronBridgeResult | null> {
if (!this.supportsEpgMapping) {
return Promise.resolve(null);
}
return (
this.bridge?.setEpgMapping?.(channelKey, epgChannelId, playlistId) ??
Promise.resolve(null)
);
}
deleteEpgMapping(channelKey: string): Promise<ElectronBridgeResult | null> {
if (!this.supportsEpgMapping) {
return Promise.resolve(null);
}
return this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null);
}
searchEpgChannels(
searchTerm: string,
limit?: number
): Promise<ElectronBridgeEpgSearchResult[] | null> {
if (!this.supportsEpgMapping) {
return Promise.resolve(null);
}
return (
this.bridge?.searchEpgChannels?.(searchTerm, limit) ??
Promise.resolve(null)
);
}
onProgress(callback: (data: EpgImportProgress) => void): void {
if (!this.supportsProgress) {
return;
@@ -228,6 +228,7 @@
@if (
item.status === 'downloading' ||
item.status === 'paused' ||
item.status === 'completed'
) {
<span class="downloads__item-size">
@@ -253,6 +254,17 @@
</div>
@if (item.status === 'downloading') {
<mat-progress-bar
[mode]="
item.totalBytes
? 'determinate'
: 'indeterminate'
"
[value]="getProgress(item)"
></mat-progress-bar>
} @else if (
item.status === 'paused' && item.totalBytes
) {
<mat-progress-bar
mode="determinate"
[value]="getProgress(item)"
@@ -274,6 +286,18 @@
</button>
@switch (item.status) {
@case ('queued') {
<button
mat-icon-button
(click)="
$event.stopPropagation();
pause(item)
"
[matTooltip]="
'DOWNLOADS.PAUSE' | translate
"
>
<mat-icon>pause</mat-icon>
</button>
<button
mat-icon-button
(click)="
@@ -288,6 +312,18 @@
</button>
}
@case ('downloading') {
<button
mat-icon-button
(click)="
$event.stopPropagation();
pause(item)
"
[matTooltip]="
'DOWNLOADS.PAUSE' | translate
"
>
<mat-icon>pause</mat-icon>
</button>
<button
mat-icon-button
(click)="
@@ -301,6 +337,46 @@
<mat-icon>close</mat-icon>
</button>
}
@case ('paused') {
<button
mat-icon-button
color="primary"
(click)="
$event.stopPropagation();
resume(item)
"
[matTooltip]="
'DOWNLOADS.RESUME' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
<button
mat-icon-button
(click)="
$event.stopPropagation();
cancel(item)
"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="
$event.stopPropagation();
remove(item)
"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('completed') {
<button
mat-icon-button
@@ -442,6 +442,11 @@
&.status-queued {
color: var(--mat-sys-on-surface-variant);
}
&.status-paused {
color: #f59e0b;
background: color-mix(in srgb, #f59e0b 14%, transparent);
}
}
&-size {
@@ -212,6 +212,8 @@ export class DownloadsComponent {
return 'schedule';
case 'downloading':
return 'downloading';
case 'paused':
return 'pause_circle';
case 'completed':
return 'check_circle';
case 'failed':
@@ -229,6 +231,8 @@ export class DownloadsComponent {
return 'status-queued';
case 'downloading':
return 'status-downloading';
case 'paused':
return 'status-paused';
case 'completed':
return 'status-completed';
case 'failed':
@@ -257,15 +261,38 @@ export class DownloadsComponent {
}
async cancel(item: DownloadItem) {
await this.downloadsService.cancelDownload(item.id);
const result = await this.downloadsService.cancelDownload(item.id);
if (!result.success) {
this.showActionError(result.error);
}
}
async pause(item: DownloadItem) {
const result = await this.downloadsService.pauseDownload(item.id);
if (!result.success) {
this.showActionError(result.error);
}
}
async resume(item: DownloadItem) {
const result = await this.downloadsService.resumeDownload(item.id);
if (!result.success) {
this.showActionError(result.error);
}
}
async retry(item: DownloadItem) {
await this.downloadsService.retryDownload(item.id);
const result = await this.downloadsService.retryDownload(item.id);
if (!result.success) {
this.showActionError(result.error);
}
}
async remove(item: DownloadItem) {
await this.downloadsService.removeDownload(item.id);
const result = await this.downloadsService.removeDownload(item.id);
if (!result.success) {
this.showActionError(result.error);
}
}
async play(item: DownloadItem) {
@@ -571,6 +598,17 @@ export class DownloadsComponent {
return `${item.id}:${item.posterUrl ?? ''}`;
}
private showActionError(error?: string): void {
const message = error
? `${this.translate.instant('DOWNLOADS.ACTION_FAILED')}: ${error}`
: this.translate.instant('DOWNLOADS.ACTION_FAILED');
this.snackBar.open(message, undefined, {
duration: 4000,
horizontalPosition: 'start',
});
}
private handleFileActionError(error?: string): void {
const message =
error === 'File not found'
@@ -44,6 +44,8 @@ describe('StreamResolverService', () => {
};
epgBridge = {
getChannelPrograms: jest.fn(),
getEpgMapping: jest.fn().mockResolvedValue(null),
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
supportsProgramLookup: true,
};
@@ -267,7 +269,7 @@ describe('StreamResolverService', () => {
password: 'pass',
},
1,
10,
50,
{
suppressErrorLog: true,
}
@@ -342,7 +344,7 @@ describe('StreamResolverService', () => {
password: 'pass',
},
1,
2,
5,
{
suppressErrorLog: true,
}
@@ -416,7 +418,7 @@ describe('StreamResolverService', () => {
stop_timestamp: '1774526400',
},
]),
10_000
30_000
);
})
);
@@ -431,7 +433,7 @@ describe('StreamResolverService', () => {
xtreamId: 1,
} satisfies UnifiedCollectionItem);
await jest.advanceTimersByTimeAsync(3000);
await jest.advanceTimersByTimeAsync(10_000);
await expect(detailPromise).resolves.toMatchObject({
epgMode: 'portal',
@@ -445,6 +447,100 @@ describe('StreamResolverService', () => {
}
});
it('resolves manual EPG mappings for Xtream previews with one batched lookup', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
const nowMs = Date.now();
epgBridge.getEpgMappingsBatch = jest.fn().mockResolvedValue({
'xtream:xtream-1:7': 'mapped.channel.id',
});
epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([
{
channel: 'mapped.channel.id',
title: 'Mapped Program',
desc: 'From uploaded XMLTV',
start: new Date(nowMs - 60_000).toISOString(),
stop: new Date(nowMs + 60_000).toISOString(),
},
]);
const epgMap = await service.loadEpgForItems([
{
uid: 'xtream::xtream-1::7',
name: 'Mapped Channel',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 7,
} satisfies UnifiedCollectionItem,
]);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledWith(
expect.arrayContaining(['xtream:xtream-1:7', 'Mapped Channel'])
);
// The mapping resolved via XMLTV, so no provider EPG call is needed.
expect(xtreamApi.getShortEpg).not.toHaveBeenCalled();
expect(epgMap.get('Mapped Channel')).toMatchObject({
title: 'Mapped Program',
});
});
it('resolves manual EPG mappings for Stalker previews without hitting the portal', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
const nowMs = Date.now();
epgBridge.getEpgMappingsBatch = jest.fn().mockResolvedValue({
'stalker:stalker-1:77': 'mapped.channel.id',
});
epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([
{
channel: 'mapped.channel.id',
title: 'Mapped Stalker Program',
desc: 'From uploaded XMLTV',
start: new Date(nowMs - 60_000).toISOString(),
stop: new Date(nowMs + 60_000).toISOString(),
},
]);
const epgMap = await service.loadEpgForItems([
{
uid: 'stalker::stalker-1::77',
name: 'Stalker Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '77',
tvgId: '77',
stalkerCmd: 'ffmpeg http://stalker/77',
} satisfies UnifiedCollectionItem,
]);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
expect(epgMap.get('77')).toMatchObject({
title: 'Mapped Stalker Program',
});
// The mapped channel resolves from uploaded XMLTV — the portal
// short-EPG request must be skipped entirely.
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('loads current Stalker EPG previews for live collection rows', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -3,6 +3,8 @@ import { firstValueFrom } from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
Channel,
EpgItem,
EpgProgram,
@@ -63,11 +65,11 @@ export class StreamResolverService {
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly m3uEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 10000;
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 30 * 1000;
private get supportsProgramLookup(): boolean {
return this.epgBridge.supportsProgramLookup;
@@ -410,11 +412,59 @@ export class StreamResolverService {
return [];
}
// 1) Check uploaded XMLTV EPG via the provider's epg_channel_id.
// The field is populated at runtime from the content table's
// epg_channel_id column but is not declared on the TS interface.
const epgKey = (item as unknown as Record<string, string | undefined | null>).epgChannelId?.trim();
if (this.supportsProgramLookup && epgKey) {
const uploaded = await this.epgBridge
.getChannelPrograms(epgKey)
.catch(() => null);
if (uploaded && uploaded.length > 0) {
return this.mapProgramsToEpgItems(uploaded);
}
}
// 2) Fall back to the manual mapping table (playlist-scoped
// Xtream key → epgChannelId).
if (this.supportsProgramLookup && item.xtreamId) {
const mapping = await this.epgBridge
.getEpgMapping(
buildXtreamEpgMappingKey(item.playlistId, item.xtreamId)
)
.catch(() => null);
if (mapping?.epgChannelId) {
const mapped = await this.epgBridge
.getChannelPrograms(mapping.epgChannelId)
.catch(() => null);
if (mapped && mapped.length > 0) {
return this.mapProgramsToEpgItems(mapped);
}
}
}
// 3) Try the full EPG endpoint (same as the main live-view
// loadEpg() in with-epg.feature.ts) — many providers only
// support get_simple_data_table, not get_short_epg.
try {
const fullEpg = await this.xtreamApi.getFullEpg(
creds,
item.xtreamId,
{ suppressErrorLog: true }
);
if (fullEpg.length > 0) {
return fullEpg;
}
} catch {
// getFullEpg failed — continue to short-EPG fallback below.
}
// Fall back to the short-EPG endpoint with a generous limit.
return await this.fetchXtreamEpgItems(
item.playlistId,
creds,
item.xtreamId,
10
50
);
} catch {
return [];
@@ -440,10 +490,35 @@ export class StreamResolverService {
return [];
}
// Manual mapping first — Stalker items carry no provider XMLTV id,
// so the mapping table is the only uploaded-EPG entry point.
if (this.supportsProgramLookup) {
const mapping = await this.epgBridge
.getEpgMapping(
buildStalkerEpgMappingKey(item.playlistId, channelId)
)
.catch(() => null);
if (mapping?.epgChannelId) {
const mapped = await this.epgBridge
.getChannelPrograms(mapping.epgChannelId)
.catch(() => null);
if (mapped && mapped.length > 0) {
return this.mapProgramsToEpgItems(mapped);
}
}
}
return this.fetchStalkerShortEpg(playlist, channelId, size);
}
private async getXtreamCredentials(playlistId: string) {
private async getXtreamCredentials(
playlistId: string
): Promise<{
serverUrl: string;
username: string;
password: string;
serverTimezone?: string;
} | null> {
const playlist =
(await this.getElectronPlaylist(playlistId)) ??
((await firstValueFrom(
@@ -458,9 +533,37 @@ export class StreamResolverService {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
serverTimezone: playlist.serverTimezone,
};
}
/**
* Resolve an Xtream archive/catch-up URL for a given programme.
* Returns the timeshift playback URL, or null when credentials are
* missing or the provider doesn't support it.
*/
async resolveXtreamCatchupUrl(
item: UnifiedCollectionItem,
startTimestamp: number,
stopTimestamp: number
): Promise<string | null> {
const creds = await this.getXtreamCredentials(item.playlistId);
if (!creds || !item.xtreamId) return null;
try {
return await this.xtreamUrl.resolveCatchupUrl(
item.playlistId,
creds,
item.xtreamId,
startTimestamp,
stopTimestamp,
creds.serverTimezone
);
} catch {
return null;
}
}
private async loadM3uEpg(
item: UnifiedCollectionItem,
epgMap: Map<string, EpgProgram | null>,
@@ -560,31 +663,78 @@ export class StreamResolverService {
return;
}
await Promise.all(
channels.map(async (channel) => {
if (!channel.xtreamId) {
return;
}
// Prefetch manual EPG mappings for the whole batch in one IPC
// round-trip — a per-channel lookup would issue O(N × candidates)
// IPC calls on every list load.
const mappingByKey = await this.prefetchEpgMappings(
playlistId,
channels
);
// Limit concurrency to avoid overwhelming the provider with
// simultaneous EPG requests when loading a large channel list.
const concurrency = 3;
const pending: Promise<void>[] = [];
const iterator = channels.entries();
const enqueueNext = async (): Promise<void> => {
for (;;) {
const entry = iterator.next();
if (entry.done) return;
const [, channel] = entry.value;
if (!channel.xtreamId) continue;
try {
const items = await this.fetchXtreamEpgItems(
playlistId,
creds,
channel.xtreamId,
2
);
const nowSeconds = Math.floor(now / 1000);
const currentItem =
items.find(
(item) =>
Number(item.start_timestamp) <= nowSeconds &&
nowSeconds < Number(item.stop_timestamp)
) ?? null;
let currentItem: EpgItem | null = null;
// 1) Try uploaded XMLTV EPG via the provider's epg_channel_id.
const epgChannelKey = (channel as unknown as Record<string, string | undefined | null>).epgChannelId?.trim();
if (this.supportsProgramLookup && epgChannelKey) {
currentItem = await this.findCurrentInXmltv(
epgChannelKey, nowSeconds
);
}
// 2) Fall back to manual mapping (epg_channel_mappings
// table), using the batch-prefetched map. Candidate key
// order matches how mappings can be saved: the
// playlist-scoped Xtream key (portal/favorites dialogs),
// then tvgId and name (M3U dialogs).
if (!currentItem && this.supportsProgramLookup) {
for (const key of this.mappingCandidateKeys(
playlistId,
channel
)) {
const mappedId = mappingByKey.get(key);
if (!mappedId) continue;
currentItem = await this.findCurrentInXmltv(
mappedId,
nowSeconds
);
if (currentItem) break;
}
}
if (!currentItem) {
const items = await this.fetchXtreamEpgItems(
playlistId,
creds,
channel.xtreamId,
5
);
currentItem =
items.find(
(item) =>
Number(item.start_timestamp) <= nowSeconds &&
nowSeconds < Number(item.stop_timestamp)
) ?? null;
}
const epgKey =
channel.tvgId?.trim() || channel.name?.trim();
if (!epgKey) {
return;
continue;
}
epgMap.set(
@@ -611,8 +761,14 @@ export class StreamResolverService {
epgMap.set(epgKey, null);
}
}
})
);
}
};
// Start limited concurrent workers.
for (let i = 0; i < concurrency; i++) {
pending.push(enqueueNext());
}
await Promise.all(pending);
}
private getXtreamEpgCacheKey(
@@ -711,6 +867,14 @@ export class StreamResolverService {
return;
}
// One batched IPC round-trip for the manual mappings of the whole
// playlist batch — mirrors loadXtreamEpgBatch.
const mappingByKey = await this.prefetchEpgMappings(
playlistId,
channels
);
const nowSeconds = Math.floor(now / 1000);
await Promise.all(
channels.map(async (channel) => {
const channelId = String(channel.stalkerId ?? '').trim();
@@ -722,6 +886,25 @@ export class StreamResolverService {
}
try {
for (const key of this.mappingCandidateKeys(
playlistId,
channel
)) {
const mappedId = mappingByKey.get(key);
if (!mappedId) continue;
const mappedItem = await this.findCurrentInXmltv(
mappedId,
nowSeconds
);
if (mappedItem) {
epgMap.set(
epgKey,
this.toPreviewProgram(mappedItem, channelId, now)
);
return;
}
}
const items = await this.fetchStalkerShortEpg(
playlist,
channelId,
@@ -923,4 +1106,91 @@ export class StreamResolverService {
private isHttpUrl(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://');
}
/**
* All keys a manual mapping for this channel may have been saved under:
* the playlist-scoped Xtream key first, then the M3U lookup keys.
*/
private mappingCandidateKeys(
playlistId: string,
channel: UnifiedCollectionItem
): string[] {
// Stalker mappings are only ever saved under the playlist-scoped
// key, and a stalker item's tvgId mirrors its raw provider id —
// bare tvgId/name candidates could only produce false matches
// against unrelated M3U mappings.
if (channel.sourceType === 'stalker') {
return channel.stalkerId != null
? [buildStalkerEpgMappingKey(playlistId, channel.stalkerId)]
: [];
}
return [
channel.xtreamId != null
? buildXtreamEpgMappingKey(playlistId, channel.xtreamId)
: null,
channel.tvgId?.trim() || null,
channel.name?.trim() || null,
].filter((key): key is string => Boolean(key));
}
/**
* Resolve the manual EPG mappings for every candidate key in the batch
* with a single IPC call. Returns an empty map when program lookup or
* the mapping bridge is unavailable (PWA).
*/
private async prefetchEpgMappings(
playlistId: string,
channels: UnifiedCollectionItem[]
): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (!this.supportsProgramLookup) {
return result;
}
const keys = new Set<string>();
for (const channel of channels) {
if (channel.xtreamId == null && channel.stalkerId == null) {
continue;
}
for (const key of this.mappingCandidateKeys(playlistId, channel)) {
keys.add(key);
}
}
if (keys.size === 0) {
return result;
}
const mappings = await this.epgBridge
.getEpgMappingsBatch([...keys])
.catch(() => null);
if (!mappings) {
return result;
}
for (const [key, mappedId] of Object.entries(mappings)) {
const trimmed = mappedId?.trim();
if (trimmed) {
result.set(key, trimmed);
}
}
return result;
}
/** Look up the current program for an EPG channel ID from uploaded XMLTV. */
private async findCurrentInXmltv(
epgChannelId: string,
nowSeconds: number
): Promise<EpgItem | null> {
if (!this.supportsProgramLookup || !epgChannelId) return null;
const programs = await this.epgBridge
.getChannelPrograms(epgChannelId)
.catch(() => null);
if (!programs || programs.length === 0) return null;
const items = this.mapProgramsToEpgItems(programs);
return items.find(
(item) =>
Number(item.start_timestamp) <= nowSeconds &&
nowSeconds < Number(item.stop_timestamp)
) ?? null;
}
}
@@ -209,6 +209,8 @@ describe('UnifiedFavoritesDataService', () => {
title: 'Live One',
type: 'live',
poster_url: 'live.png',
tv_archive: 1,
tv_archive_duration: 7,
added_at: '2026-03-26T09:00:00.000Z',
position: 0,
},
@@ -247,6 +249,10 @@ describe('UnifiedFavoritesDataService', () => {
contentType: 'live',
logo: 'live.png',
posterUrl: null,
// Regression for issue #1138: archive metadata must
// survive the global favorites mapping for catch-up.
tvArchive: 1,
tvArchiveDuration: 7,
}),
expect.objectContaining({
name: 'Movie One',
@@ -609,6 +609,8 @@ export class UnifiedFavoritesDataService {
categoryId: row.category_id,
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
rating: row.rating ?? undefined,
tvArchive: row.tv_archive ?? null,
tvArchiveDuration: row.tv_archive_duration ?? null,
addedAt:
normalizeStalkerDate(row.added_at) || new Date(0).toISOString(),
position: row.position ?? 0,
@@ -635,6 +637,8 @@ export class UnifiedFavoritesDataService {
categoryId: item.category_id,
tvgId: ct === 'live' ? String(item.xtream_id) : undefined,
rating: item.rating ?? undefined,
tvArchive: item.tv_archive ?? null,
tvArchiveDuration: item.tv_archive_duration ?? null,
addedAt:
normalizeStalkerDate(item.added_at ?? item.added) ||
new Date(0).toISOString(),
@@ -355,6 +355,8 @@ describe('UnifiedRecentDataService', () => {
poster_url: 'https://example.com/live.png',
xtream_id: 290,
type: 'live',
tv_archive: 1,
tv_archive_duration: 3,
viewed_at: '2026-04-21T20:42:27.000Z',
},
{
@@ -380,6 +382,10 @@ describe('UnifiedRecentDataService', () => {
uid: 'xtream::xtream-1::live:290',
contentType: 'live',
contentId: 3867578,
// Regression for issue #1138: archive metadata must
// survive the recent mapping for catch-up.
tvArchive: 1,
tvArchiveDuration: 3,
}),
expect.objectContaining({
uid: 'xtream::xtream-1::series:290',
@@ -697,10 +703,25 @@ describe('UnifiedRecentDataService', () => {
playlist_name: 'Xtream One',
viewed_at: '2026-04-21 22:49:02',
},
{
id: 3867578,
category_id: 11,
title: 'Live With Archive',
poster_url: 'https://example.com/live.png',
xtream_id: 290,
type: 'live',
tv_archive: 1,
tv_archive_duration: 3,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
viewed_at: '2026-04-21 20:42:27',
},
]);
const items = await service.getRecentItems('all');
const xtreamItem = items.find((item) => item.sourceType === 'xtream');
const xtreamItem = items.find(
(item) => item.name === 'Unter Nachbarn - 2011'
);
expect(xtreamItem).toEqual(
expect.objectContaining({
@@ -708,5 +729,16 @@ describe('UnifiedRecentDataService', () => {
viewedAt: '2026-04-21T22:49:02.000Z',
})
);
// Regression for issue #1138: archive metadata must survive the
// global recent mapping for catch-up.
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Live With Archive',
tvArchive: 1,
tvArchiveDuration: 3,
}),
])
);
});
});
@@ -389,6 +389,8 @@ export class UnifiedRecentDataService {
xtreamId: row.xtream_id,
categoryId: row.category_id,
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
tvArchive: row.tv_archive ?? null,
tvArchiveDuration: row.tv_archive_duration ?? null,
contentId: row.id,
viewedAt: normalizeStalkerDate(row.viewed_at),
}));
@@ -428,6 +430,8 @@ export class UnifiedRecentDataService {
xtreamId: row.xtream_id,
categoryId: row.category_id,
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
tvArchive: row.tv_archive ?? null,
tvArchiveDuration: row.tv_archive_duration ?? null,
contentId: row.id,
viewedAt: normalizeStalkerDate(row.viewed_at),
}));
@@ -485,6 +489,8 @@ export class UnifiedRecentDataService {
xtreamId: item.xtream_id,
categoryId: item.category_id,
tvgId: contentType === 'live' ? String(item.xtream_id) : undefined,
tvArchive: item.tv_archive ?? null,
tvArchiveDuration: item.tv_archive_duration ?? null,
contentId: item.id,
viewedAt: normalizeStalkerDate(item.viewed_at),
};
@@ -61,6 +61,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
@if (contextMenuChannel()?.m3uChannel) {
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
@@ -21,7 +21,14 @@ import {
ChannelListItemComponent,
} from '@iptvnator/ui/components';
import { SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { EpgMappingDialogComponent } from '@iptvnator/ui/components';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
@@ -52,6 +59,8 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent';
})
export class GlobalFavoritesListComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
private readonly settingsStore = inject(SettingsStore);
readonly contextMenuTrigger =
@@ -77,6 +86,8 @@ export class GlobalFavoritesListComponent {
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly removeRequested = output<UnifiedFavoriteChannel>();
/** Emitted after the mapping dialog closes having changed a mapping. */
readonly epgMappingChanged = output<void>();
readonly contextMenuChannel = signal<EnrichedUnifiedFavorite | null>(null);
readonly contextMenuPosition = signal({
@@ -163,7 +174,75 @@ export class GlobalFavoritesListComponent {
}
hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean {
return Boolean(channel.m3uChannel) || this.mode() === 'recent';
return (
Boolean(channel.m3uChannel) ||
this.mode() === 'recent' ||
(this.supportsEpgMapping &&
(channel.xtreamId != null ||
Boolean(this.stalkerItemId(channel))))
);
}
openEpgMapping(): void {
const item = this.contextMenuChannel();
if (!item) {
return;
}
this.contextMenuTrigger().closeMenu();
const stalkerId = this.stalkerItemId(item);
const channelKey = item.m3uChannel
? resolveChannelEpgLookupKey(item.m3uChannel)
: item.xtreamId != null
? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId)
: stalkerId
? buildStalkerEpgMappingKey(item.playlistId, stalkerId)
: null;
if (!channelKey) {
return;
}
void this.openEpgMappingDialog(channelKey, item);
}
private async openEpgMappingDialog(
channelKey: string,
item: UnifiedFavoriteChannel
): Promise<void> {
const before = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: item.name,
playlistId: item.m3uChannel ? undefined : item.playlistId,
})
.afterClosed()
.subscribe(async () => {
const after = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
if (
(after?.epgChannelId ?? null) !==
(before?.epgChannelId ?? null)
) {
this.epgMappingChanged.emit();
}
});
}
/**
* The stalker item id lives in the uid's third segment
* (`stalker::{playlistId}::{stalkerId}`) — same extraction the
* unified favorites data service uses.
*/
private stalkerItemId(channel: UnifiedFavoriteChannel): string | null {
if (channel.sourceType !== 'stalker') {
return null;
}
const id = channel.uid.split('::')[2]?.trim();
return id || null;
}
openChannelDetails(): void {
@@ -106,35 +106,6 @@ mat-card:hover {
}
}
// ─── Type badge ──────────────────────────────────────────────────────────────
.type-badge {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
padding: 3px 6px;
border-radius: 5px;
font-size: 0.58rem;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(0, 0, 0, 0.85);
color: #fff;
&.live {
color: #ef5350;
}
&.movie {
color: #42a5f5;
}
&.series {
color: #66bb6a;
}
}
// ─── Rating badge ─────────────────────────────────────────────────────────────
.rating {
position: absolute;
@@ -59,7 +59,7 @@ describe('GridListComponent', () => {
fixture = TestBed.createComponent(GridListComponent);
});
it('renders live logo cards with stream icons and a live badge', () => {
it('renders live logo cards with stream icons', () => {
fixture.componentRef.setInput('items', [
{
name: 'Live Channel',
@@ -73,16 +73,27 @@ describe('GridListComponent', () => {
const card = fixture.debugElement.query(By.css('mat-card'));
const image = fixture.debugElement.query(By.css('.stream-icon'));
const badge = fixture.debugElement.query(By.css('.type-badge'));
expect(card.nativeElement.classList).toContain('grid-card--logo');
expect(image.nativeElement.getAttribute('src')).toBe(
'channel-logo.png'
);
expect(badge.nativeElement.classList).toContain('live');
expect(badge.nativeElement.textContent.trim()).toBe('live');
});
it.each(['live', 'vod', 'series'] as const)(
'does not render a redundant %s type badge in homogeneous grids',
(type) => {
fixture.componentRef.setInput('items', [
{ name: 'Catalog item', stream_icon: 'catalog-item.png' },
]);
fixture.componentRef.setInput('type', type);
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.type-badge'))
).toBeNull();
}
);
it('renders the live placeholder for logo cards without artwork', () => {
fixture.componentRef.setInput('items', [
{
@@ -106,16 +106,6 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
>
@let poster = resolvePoster(i);
<div class="card-thumbnail-container">
@if (type()) {
<div
class="type-badge"
[class.live]="type() === 'live'"
[class.movie]="type() === 'vod'"
[class.series]="type() === 'series'"
>
{{ type() }}
</div>
}
@if (poster && !hasArtworkFailed(poster)) {
<img
class="stream-icon"
@@ -94,6 +94,22 @@ function findCurrentPortalProgram(
);
}
/**
* Convert program start/stop to epoch seconds, preferring the
* numeric timestamp (which is provider-native) over the ISO string.
*/
export function toEpochSeconds(
timestamp: number | null | undefined,
fallbackIso: string
): number | null {
if (timestamp != null && Number.isFinite(timestamp)) {
return timestamp;
}
const ms = Date.parse(fallbackIso);
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
}
function getProgramTimeMs(
rawDate: string | null | undefined,
rawTimestamp?: number | string | null
@@ -20,6 +20,7 @@
(channelSelected)="onChannelSelected($event)"
(playbackRequested)="onChannelPlaybackRequested($event)"
(channelsReordered)="onReorder($event)"
(epgMappingChanged)="onEpgMappingChanged()"
(favoriteToggled)="onFavoriteToggled($event)"
(removeRequested)="onRemoveRequested($event)"
/>
@@ -140,6 +140,7 @@ describe('UnifiedLiveTabComponent', () => {
let streamResolver: {
resolveLiveDetail: jest.Mock;
resolveM3uPlaybackDetail: jest.Mock;
resolveXtreamCatchupUrl: jest.Mock;
loadM3uProgramsForItem: jest.Mock;
loadEpgForItems: jest.Mock;
};
@@ -164,6 +165,7 @@ describe('UnifiedLiveTabComponent', () => {
streamResolver = {
resolveLiveDetail: jest.fn(),
resolveM3uPlaybackDetail: jest.fn(),
resolveXtreamCatchupUrl: jest.fn().mockResolvedValue(null),
loadM3uProgramsForItem: jest.fn().mockResolvedValue([]),
loadEpgForItems: jest.fn().mockResolvedValue(new Map()),
};
@@ -1065,6 +1067,108 @@ describe('UnifiedLiveTabComponent', () => {
});
});
describe('Xtream (portal) catch-up (timeshift) playback', () => {
const selectXtreamArchiveChannel = async (
archive: Partial<UnifiedCollectionItem> = {
tvArchive: 1,
tvArchiveDuration: 5,
}
) => {
const item = { ...buildLiveItem('xtream'), ...archive };
streamResolver.resolveLiveDetail.mockResolvedValue({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/xtream.m3u8',
title: 'Xtream Live',
},
epgItems: [buildEpgItem('Xtream Show')],
});
recentData.recordLivePlayback.mockResolvedValue(item);
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
return item;
};
const timeshiftEvent = (): EpgProgramActivationEvent => ({
program: buildProgram('Xtream Show'),
type: 'timeshift',
});
const queryTimeline = () =>
fixture.debugElement.query(By.directive(StubEpgTimelineComponent))
.componentInstance as StubEpgTimelineComponent;
it('exposes the provider archive window to the timeline in days', async () => {
// Regression: tv_archive_duration is days (matching
// live-stream-layout.controlledArchiveDays), not hours — a
// 5-day window must not collapse to ceil(5 / 24) = 1 day.
await selectXtreamArchiveChannel({
tvArchive: 1,
tvArchiveDuration: 5,
});
const timeline = queryTimeline();
expect(timeline.archivePlaybackAvailable()).toBe(true);
expect(timeline.archiveDays()).toBe(5);
});
it('keeps the archive gate closed when the provider has no archive', async () => {
await selectXtreamArchiveChannel({
tvArchive: 0,
tvArchiveDuration: 0,
});
const timeline = queryTimeline();
expect(timeline.archivePlaybackAvailable()).toBe(false);
expect(timeline.archiveDays()).toBe(0);
});
it('resolves the catch-up stream with epoch seconds and switches inline playback', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
streamResolver.resolveXtreamCatchupUrl.mockResolvedValue(
'https://example.com/timeshift.m3u8'
);
const item = await selectXtreamArchiveChannel();
component.onTimelineProgramActivated(timeshiftEvent());
await fixture.whenStable();
fixture.detectChanges();
const program = timeshiftEvent().program;
expect(streamResolver.resolveXtreamCatchupUrl).toHaveBeenCalledWith(
expect.objectContaining({ xtreamId: item.xtreamId }),
Math.floor(Date.parse(program.start) / 1000),
Math.floor(Date.parse(program.stop) / 1000)
);
expect(component.inlinePlayback()?.streamUrl).toBe(
'https://example.com/timeshift.m3u8'
);
expect(component.inlinePlayback()?.isLive).toBe(false);
expect(component.activeTimeshiftProgram()?.title).toBe(
'Xtream Show'
);
expect(snackBar.open).not.toHaveBeenCalled();
});
it('shows feedback instead of failing silently when the provider rejects catch-up', async () => {
streamResolver.resolveXtreamCatchupUrl.mockResolvedValue(null);
await selectXtreamArchiveChannel();
component.onTimelineProgramActivated(timeshiftEvent());
await fixture.whenStable();
expect(component.activeTimeshift()).toBeNull();
expect(snackBar.open).toHaveBeenCalledTimes(1);
});
});
describe('timeline channel name', () => {
it('strips the country prefix when the setting is enabled', () => {
stripCountryPrefix.set(true);
@@ -48,6 +48,7 @@ import {
import {
getLiveEpgPanelSummary,
toEpgProgram,
toEpochSeconds,
toLiveEpgPanelSummary,
} from './unified-live-epg-summary.util';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
@@ -114,6 +115,10 @@ export class UnifiedLiveTabComponent {
readonly activeDetail = signal<ResolvedLiveCollectionDetail | null>(null);
readonly activeUid = signal<string | null>(null);
/** Full collection item for the active selection — used to read
* portal archive fields (tvArchive/tvArchiveDuration) and to
* supply credentials for Xtream catch-up URL resolution. */
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
readonly isSelecting = signal(false);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
@@ -197,22 +202,41 @@ export class UnifiedLiveTabComponent {
this.activeDetail()?.playback?.thumbnail ??
''
);
readonly timelineArchiveAvailable = computed(
() =>
this.isM3uSelection() && this.currentM3uArchivePlaybackAvailable()
);
readonly timelineArchiveAvailable = computed(() => {
if (this.isM3uSelection()) {
return this.currentM3uArchivePlaybackAvailable();
}
// Portal archive: tvArchive === 1 means the provider has
// timeshift / archive enabled for this channel.
const item = this.activeItem();
return (
Number(item?.tvArchive ?? 0) === 1 &&
Number(item?.tvArchiveDuration ?? 0) > 0
);
});
/**
* Catch-up window (days) for the active M3U channel, so the timeline can
* Catch-up window (days) for the active channel, so the timeline can
* gate "Watch" to programmes inside it. Without this the timeline defaults
* `archiveDays` to 0 (treated as unlimited) and offers catch-up on
* programmes older than the channel's real `catchup-days`/`timeshift`/
* `tvg-rec` window. 0 for portal selections (archive is M3U-only here).
* programmes older than the real archive window.
*
* M3U: reads catchup-days / timeshift / tvg-rec from the channel attrs.
* Portal: tvArchiveDuration is already in days — pass it through,
* matching `live-stream-layout.controlledArchiveDays`.
*/
readonly timelineArchiveDays = computed(() =>
this.timelineArchiveAvailable()
? getM3uArchiveDays(this.currentM3uChannel())
: 0
);
readonly timelineArchiveDays = computed(() => {
if (!this.timelineArchiveAvailable()) return 0;
if (this.isM3uSelection()) {
return getM3uArchiveDays(this.currentM3uChannel());
}
return Math.max(
0,
Number(this.activeItem()?.tvArchiveDuration ?? 0) || 0
);
});
readonly activeRadioChannel = computed(() => {
const channel = this.activeDetail()?.channel ?? null;
return channel?.radio === 'true' ? channel : null;
@@ -389,9 +413,20 @@ export class UnifiedLiveTabComponent {
return;
}
if (this.isM3uSelection()) {
this.activateM3uCatchup(event.program);
} else {
void this.activatePortalCatchup(event.program);
}
}
/**
* M3U catch-up: resolve via the M3U timeshift URL resolver.
*/
private activateM3uCatchup(program: EpgProgram): void {
const playbackUrl = resolveM3uCatchupUrl(
this.currentM3uChannel(),
event.program
program
);
if (!playbackUrl) {
this.snackBar.open(
@@ -402,10 +437,70 @@ export class UnifiedLiveTabComponent {
return;
}
this.activeTimeshift.set({ url: playbackUrl, program: event.program });
this.activeTimeshift.set({ url: playbackUrl, program });
const playback = this.activeDetail()?.playback;
if (!this.shouldUseInlinePlayer() && playback) {
void this.portalPlayer.openResolvedPlayback({
...playback,
streamUrl: playbackUrl,
isLive: false,
});
}
}
/**
* Portal (Xtream) catch-up: compute start/stop as epoch seconds and
* resolve via the provider's timeshift endpoint.
*/
private async activatePortalCatchup(program: EpgProgram): Promise<void> {
const requestId = this.selectionRequestId;
const item = this.activeItem();
if (!item?.xtreamId) {
this.snackBar.open(
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
undefined,
{ duration: 4000 }
);
return;
}
const startEpoch = toEpochSeconds(
program.startTimestamp,
program.start
);
const stopEpoch = toEpochSeconds(
program.stopTimestamp,
program.stop
);
if (startEpoch == null || stopEpoch == null) {
this.snackBar.open(
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
undefined,
{ duration: 4000 }
);
return;
}
const playbackUrl = await this.streamResolver.resolveXtreamCatchupUrl(
item,
startEpoch,
stopEpoch
);
if (requestId !== this.selectionRequestId) {
return; // switched channel — discard silently
}
if (!playbackUrl) {
this.snackBar.open(
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
undefined,
{ duration: 4000 }
);
return;
}
this.activeTimeshift.set({ url: playbackUrl, program });
// No inline player (external MPV/VLC configured) → hand the archive
// stream to the external player, mirroring the live-selection flow.
const playback = this.activeDetail()?.playback;
if (!this.shouldUseInlinePlayer() && playback) {
void this.portalPlayer.openResolvedPlayback({
@@ -441,9 +536,14 @@ export class UnifiedLiveTabComponent {
this.isSelecting.set(false);
this.activeDetail.set(null);
this.activeUid.set(null);
this.activeItem.set(null);
this.activeTimeshift.set(null);
}
onEpgMappingChanged(): void {
void this.loadEpgMap(this.items());
}
private async loadEpgMap(items: UnifiedCollectionItem[]): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
@@ -472,6 +572,7 @@ export class UnifiedLiveTabComponent {
const requestId = ++this.selectionRequestId;
this.activeUid.set(item.uid);
this.activeItem.set(item);
this.activeDetail.set(null);
this.activeTimeshift.set(null);
this.isSelecting.set(true);
@@ -11,6 +11,8 @@ export interface XtreamFavoriteRow {
readonly type: string;
readonly poster_url?: string | null;
readonly rating?: string | null;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | null;
readonly added_at?: string | null;
readonly position?: number | null;
}
@@ -56,6 +56,12 @@ export interface UnifiedCollectionItem {
/** Xtream DB content id — used for reorder / remove IPC */
contentId?: number;
/** Whether the Xtream provider has timeshift / archive enabled for this stream */
tvArchive?: number | null;
/** Timeshift / archive window in days — passed through as-is, matching
* `live-stream-layout.controlledArchiveDays`. */
tvArchiveDuration?: number | null;
/** Stalker cmd for stream resolution */
stalkerId?: string | number;
stalkerCmd?: string;
+45 -2
View File
@@ -14,7 +14,9 @@ describe('portal debug logger', () => {
beforeEach(() => {
globalWithNgDevMode.ngDevMode = true;
jest.spyOn(console, 'groupCollapsed').mockImplementation(() => undefined);
jest.spyOn(console, 'groupCollapsed').mockImplementation(
() => undefined
);
jest.spyOn(console, 'groupEnd').mockImplementation(() => undefined);
jest.spyOn(console, 'log').mockImplementation(() => undefined);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
@@ -36,7 +38,9 @@ describe('portal debug logger', () => {
});
logPortalDebugRequest(context);
logPortalDebugEvent(createPortalDebugSuccessEvent(context, { ok: true }));
logPortalDebugEvent(
createPortalDebugSuccessEvent(context, { ok: true })
);
expect(console.groupCollapsed).not.toHaveBeenCalled();
expect(console.log).not.toHaveBeenCalled();
@@ -85,4 +89,43 @@ describe('portal debug logger', () => {
nowSpy.mockRestore();
});
it('redacts portal request and response credentials before logging', () => {
const secrets = {
username: 'portal-user-secret',
password: 'portal-password-secret',
token: 'portal-token-secret',
authorization: 'portal-authorization-secret',
mac: 'portal-mac-secret',
};
const context = createPortalDebugRequestContext({
provider: 'stalker',
operation: 'get_profile',
transport: 'pwa-http',
request: {
params: secrets,
url: `https://example.com/portal?token=${secrets.token}&action=get_profile`,
diagnosticId: 'request-42',
},
});
logPortalDebugRequest(context);
logPortalDebugEvent(
createPortalDebugSuccessEvent(context, {
user_info: secrets,
status: 'ok',
})
);
const output = JSON.stringify([
...(console.log as jest.Mock).mock.calls,
...(console.error as jest.Mock).mock.calls,
]);
for (const secret of Object.values(secrets)) {
expect(output).not.toContain(secret);
}
expect(output).toContain('request-42');
expect(output).toContain('get_profile');
expect(output).toContain('status');
});
});
+18 -5
View File
@@ -3,6 +3,7 @@ import {
PortalDebugProvider,
PortalDebugTransport,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
export interface Logger {
debug: (...args: unknown[]) => void;
@@ -26,19 +27,31 @@ export function createLogger(scope: string): Logger {
return {
debug: (...args: unknown[]) => {
if (debugEnabled) {
console.debug(prefix, ...args);
console.debug(
prefix,
...args.map((arg) => redactSensitiveData(arg))
);
}
},
info: (...args: unknown[]) => {
if (debugEnabled) {
console.info(prefix, ...args);
console.info(
prefix,
...args.map((arg) => redactSensitiveData(arg))
);
}
},
warn: (...args: unknown[]) => {
console.warn(prefix, ...args);
console.warn(
prefix,
...args.map((arg) => redactSensitiveData(arg))
);
},
error: (...args: unknown[]) => {
console.error(prefix, ...args);
console.error(
prefix,
...args.map((arg) => redactSensitiveData(arg))
);
},
};
}
@@ -78,7 +91,7 @@ function logPortalDebugSection(
method: 'log' | 'error' = 'log'
): void {
if (typeof console[method] === 'function') {
console[method](label, value);
console[method](label, redactSensitiveData(value));
}
}
@@ -1,6 +1,7 @@
export * from './lib/models';
export * from './lib/stores';
export * from './lib/stalker-content-types';
export * from './lib/stalker-itv-cache.service';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-series.adapters';
export * from './lib/stalker-session.service';
@@ -1,5 +1,11 @@
export interface StalkerCategoryItem {
category_id: string;
category_name: string;
/**
* Ministra "censored" genre flag (adult categories). Portals typically
* exclude these channels from `get_all_channels`, so censored categories
* are served via the legacy paged flow and get no count badge.
*/
censored?: boolean;
[key: string]: unknown;
}
@@ -10,4 +10,6 @@ export interface StalkerItvChannel extends StalkerVodSource {
name?: string;
o_name?: string;
logo?: string;
tv_genre_id?: string | number;
number?: string | number;
}
@@ -0,0 +1,391 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerItvCacheService } from './stalker-itv-cache.service';
import { StalkerSessionService } from './stalker-session.service';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-14T00:00:00.000Z',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
type StalkerParams = Record<string, string | number>;
interface RequestHandlers {
allChannels?: () => Promise<unknown> | unknown;
page?: (page: number) => Promise<unknown> | unknown;
}
function channel(id: string, name: string, genreId: string) {
return {
id,
name,
cmd: `ffrt http://stream.example/${id}`,
tv_genre_id: genreId,
};
}
function pageOf(items: unknown[], totalItems: number, pageSize = 14) {
return {
js: {
data: items,
total_items: totalItems,
max_page_items: pageSize,
},
};
}
const UNSUPPORTED_ACTION = { js: { error: 'Unknown action: get_all_channels' } };
async function flushMicrotasks(times = 5): Promise<void> {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
}
}
describe('StalkerItvCacheService', () => {
let service: StalkerItvCacheService;
let sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
function mockRequests(handlers: RequestHandlers): void {
sendIpcEvent.mockImplementation(
async (_event: unknown, payload: unknown) => {
const params = (payload as { params: StalkerParams }).params;
if (params['action'] === 'get_all_channels') {
if (!handlers.allChannels) {
throw new Error('unexpected get_all_channels');
}
return handlers.allChannels();
}
if (params['action'] === 'get_ordered_list') {
if (!handlers.page) {
throw new Error('unexpected get_ordered_list');
}
return handlers.page(Number(params['p']));
}
throw new Error(`unexpected action ${params['action']}`);
}
);
}
function callsFor(action: string): number {
return sendIpcEvent.mock.calls.filter(
(call) =>
(call[1] as { params: StalkerParams }).params['action'] ===
action
).length;
}
beforeEach(() => {
sendIpcEvent = jest.fn();
TestBed.configureTestingModule({
providers: [
{ provide: DataService, useValue: { sendIpcEvent } },
{
provide: StalkerSessionService,
useValue: { makeAuthenticatedRequest: jest.fn() },
},
],
});
service = TestBed.inject(StalkerItvCacheService);
});
it('loads the full list via get_all_channels in a single request', async () => {
mockRequests({
allChannels: () =>
pageOf(
[
channel('1', 'News One', '5'),
// stream_id fallback used when id is missing
{
stream_id: '2',
name: 'Sports HD',
cmd: 'ffrt http://stream.example/2',
tv_genre_id: '9',
},
],
2
),
});
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(true);
expect(service.versionFor(PLAYLIST)).toBe(1);
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
const channels = service.getChannels(PLAYLIST);
expect(channels?.map((item) => item.id)).toEqual(['1', '2']);
expect(channels?.every((item) => typeof item.cmd === 'string')).toBe(
true
);
});
it('falls back to crawling get_ordered_list pages when get_all_channels is unsupported', async () => {
mockRequests({
allChannels: () => UNSUPPORTED_ACTION,
page: (page) =>
pageOf(
Array.from({ length: page === 2 ? 14 : 14 }, (_, index) =>
channel(
`${page}-${index}`,
`Channel ${page}-${index}`,
'5'
)
),
28
),
});
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(true);
expect(service.getChannels(PLAYLIST)).toHaveLength(28);
expect(callsFor('get_all_channels')).toBe(1);
expect(callsFor('get_ordered_list')).toBe(2);
});
it('reports crawl progress while loading and clears it afterwards', async () => {
let resolveSecondPage!: (value: unknown) => void;
mockRequests({
allChannels: () => UNSUPPORTED_ACTION,
page: (page) => {
if (page === 1) {
return pageOf(
Array.from({ length: 14 }, (_, index) =>
channel(`1-${index}`, `Channel ${index}`, '5')
),
28
);
}
return new Promise((resolve) => {
resolveSecondPage = resolve;
});
},
});
const load = service.ensureLoaded(PLAYLIST);
await flushMicrotasks();
expect(service.isLoading(PLAYLIST)).toBe(true);
expect(service.progressOf(PLAYLIST)).toEqual({ loaded: 14, total: 28 });
resolveSecondPage(
pageOf(
Array.from({ length: 14 }, (_, index) =>
channel(`2-${index}`, `Channel ${index}`, '5')
),
28
)
);
await load;
expect(service.isLoading(PLAYLIST)).toBe(false);
expect(service.progressOf(PLAYLIST)).toBeNull();
expect(service.getChannels(PLAYLIST)).toHaveLength(28);
});
it('stops the crawl early when the portal serves an empty page', async () => {
mockRequests({
allChannels: () => UNSUPPORTED_ACTION,
page: (page) =>
page === 1
? pageOf(
Array.from({ length: 14 }, (_, index) =>
channel(`1-${index}`, `Channel ${index}`, '5')
),
// Portal claims more items than it can serve.
280
)
: pageOf([], 280),
});
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(true);
expect(service.getChannels(PLAYLIST)).toHaveLength(14);
});
it('memoizes unsupported portals and keeps the legacy flow in charge', async () => {
mockRequests({
allChannels: () => UNSUPPORTED_ACTION,
page: () => pageOf([], 0),
});
await service.ensureLoaded(PLAYLIST);
const callsAfterFirstAttempt = sendIpcEvent.mock.calls.length;
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(false);
expect(sendIpcEvent.mock.calls.length).toBe(callsAfterFirstAttempt);
});
it('throttles retries after a transient failure, then retries once the cooldown elapses', async () => {
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000_000);
try {
mockRequests({
allChannels: () => Promise.reject(new Error('network down')),
page: () => Promise.reject(new Error('network down')),
});
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(false);
const callsAfterError = sendIpcEvent.mock.calls.length;
// Portal is reachable again, but within the cooldown window the
// whole portal must NOT be re-crawled (prevents a hammering loop
// when a page fails deterministically).
mockRequests({
allChannels: () => pageOf([channel('1', 'News One', '5')], 1),
});
await service.ensureLoaded(PLAYLIST);
expect(sendIpcEvent.mock.calls.length).toBe(callsAfterError);
expect(service.isReady(PLAYLIST)).toBe(false);
// After the cooldown elapses the next visit retries.
nowSpy.mockReturnValue(1_000_000 + 31_000);
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(true);
expect(service.getChannels(PLAYLIST)).toHaveLength(1);
} finally {
nowSpy.mockRestore();
}
});
it('a refresh bypasses the error cooldown', async () => {
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(2_000_000);
try {
mockRequests({
allChannels: () => Promise.reject(new Error('network down')),
page: () => Promise.reject(new Error('network down')),
});
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(false);
mockRequests({
allChannels: () => pageOf([channel('1', 'News One', '5')], 1),
});
// Same instant — cooldown still active — but an explicit refresh
// (e.g. the refresh button) must retry immediately.
await service.refresh(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(true);
} finally {
nowSpy.mockRestore();
}
});
it('deduplicates channels returned across crawl pages (portal ignoring the page param)', async () => {
const samePage = () =>
pageOf(
[
channel('1', 'News One', '5'),
channel('2', 'Sports HD', '9'),
],
// Portal claims many items but returns the same two regardless
// of `p`.
280
);
mockRequests({
allChannels: () => UNSUPPORTED_ACTION,
page: () => samePage(),
});
await service.ensureLoaded(PLAYLIST);
expect(service.isReady(PLAYLIST)).toBe(true);
expect(service.getChannels(PLAYLIST)).toHaveLength(2);
});
it('deduplicates channels returned by get_all_channels', async () => {
mockRequests({
allChannels: () =>
pageOf(
[
channel('1', 'News One', '5'),
channel('1', 'News One dup', '5'),
channel('2', 'Sports HD', '9'),
],
3
),
});
await service.ensureLoaded(PLAYLIST);
expect(
service.getChannels(PLAYLIST)?.map((c) => c.id)
).toEqual(['1', '2']);
});
it('deduplicates concurrent load requests', async () => {
let resolveAllChannels!: (value: unknown) => void;
mockRequests({
allChannels: () =>
new Promise((resolve) => {
resolveAllChannels = resolve;
}),
});
const first = service.ensureLoaded(PLAYLIST);
const second = service.ensureLoaded(PLAYLIST);
await flushMicrotasks();
resolveAllChannels(pageOf([channel('1', 'News One', '5')], 1));
await Promise.all([first, second]);
expect(callsFor('get_all_channels')).toBe(1);
expect(service.isReady(PLAYLIST)).toBe(true);
});
it('keeps serving the previous list while a refresh is in flight', async () => {
mockRequests({
allChannels: () => pageOf([channel('1', 'News One', '5')], 1),
});
await service.ensureLoaded(PLAYLIST);
let resolveRefresh!: (value: unknown) => void;
mockRequests({
allChannels: () =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
});
const refresh = service.refresh(PLAYLIST);
await flushMicrotasks();
expect(service.isReady(PLAYLIST)).toBe(true);
expect(service.getChannels(PLAYLIST)?.[0]?.name).toBe('News One');
resolveRefresh(
pageOf(
[
channel('1', 'News One', '5'),
channel('2', 'Fresh Channel', '9'),
],
2
)
);
await refresh;
expect(service.getChannels(PLAYLIST)).toHaveLength(2);
expect(service.versionFor(PLAYLIST)).toBe(2);
});
});
@@ -0,0 +1,223 @@
import { Injectable, WritableSignal, inject, signal } from '@angular/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerItvChannel } from './models';
import {
StalkerItvLoadProgress,
loadFullItvChannelList,
} from './stalker-itv-channel-loader';
import { StalkerSessionService } from './stalker-session.service';
import { StalkerRequestDeps } from './stores/utils';
export type { StalkerItvLoadProgress };
/**
* After a transient crawl failure, don't immediately re-crawl the whole portal
* on the next resource fire — a deterministically-failing page would otherwise
* trigger an unbounded re-crawl loop that hammers the portal.
*/
const ERROR_COOLDOWN_MS = 30_000;
/**
* Session cache holding the complete ITV channel list per Stalker portal.
*
* Stalker portals paginate `get_ordered_list` with a server-side page size
* (typically 14), so incremental loading can never power a complete local
* search. This service loads the full list once per portal: it first tries the
* Ministra `get_all_channels` action (all channels in a single response) and
* falls back to crawling `get_ordered_list` pages. Portals where both
* strategies fail are marked unsupported and keep the legacy paged behavior.
*/
@Injectable({ providedIn: 'root' })
export class StalkerItvCacheService {
private readonly logger = createLogger('StalkerItvCache');
private readonly requestDeps: StalkerRequestDeps = {
dataService: inject(DataService),
stalkerSession: inject(StalkerSessionService),
};
/** Portal keys whose full channel list is loaded. */
private readonly readyKeys = signal<ReadonlySet<string>>(new Set());
private readonly loadingKeys = signal<ReadonlySet<string>>(new Set());
private readonly progressByKey = signal<
Readonly<Record<string, StalkerItvLoadProgress>>
>({});
/**
* Per-portal version counters. Kept as independent signals (not one shared
* counter) so a resource that reads `versionFor(portal)` only re-fires when
* THAT portal's list becomes ready or is refreshed — a different portal's
* background load completing must never re-fire an unrelated resource.
*/
private readonly versionSignals = new Map<string, WritableSignal<number>>();
private readonly channelsByKey = new Map<string, StalkerItvChannel[]>();
private readonly unsupportedKeys = new Set<string>();
private readonly inflight = new Map<string, Promise<void>>();
/** Wall-clock (ms) until which a portal's crawl error suppresses retries. */
private readonly errorCooldownUntil = new Map<string, number>();
/**
* Reactive, per-portal readiness token. Changes only when this portal's
* full channel list becomes ready or is refreshed. Resources that serve
* from the cache read this to re-fire at the right moment without being
* disturbed by other portals' loads.
*/
versionFor(playlist: PlaylistMeta | undefined): number {
const key = this.keyFor(playlist);
return key === null ? 0 : this.versionSignalFor(key)();
}
isReady(playlist: PlaylistMeta | undefined): boolean {
const key = this.keyFor(playlist);
return key !== null && this.readyKeys().has(key);
}
isLoading(playlist: PlaylistMeta | undefined): boolean {
const key = this.keyFor(playlist);
return key !== null && this.loadingKeys().has(key);
}
progressOf(
playlist: PlaylistMeta | undefined
): StalkerItvLoadProgress | null {
const key = this.keyFor(playlist);
return key !== null ? (this.progressByKey()[key] ?? null) : null;
}
/**
* Full channel list for the portal, or null while not (yet) loaded.
* Deliberately NON-reactive: it reads only the plain `channelsByKey` map
* (an entry exists iff a load succeeded), so computeds that call it don't
* pick up the shared `readyKeys` signal and re-evaluate when an UNRELATED
* portal's list becomes ready. Reactive consumers establish their own
* per-portal dependency via {@link versionFor}; boolean readiness for UI
* stays on the reactive {@link isReady}.
*/
getChannels(playlist: PlaylistMeta | undefined): StalkerItvChannel[] | null {
const key = this.keyFor(playlist);
return key === null ? null : (this.channelsByKey.get(key) ?? null);
}
/**
* Loads the full channel list once per portal. No-op when the list is
* already loaded, currently loading, or the portal is unsupported.
*/
async ensureLoaded(playlist: PlaylistMeta | undefined): Promise<void> {
const key = this.keyFor(playlist);
if (
key === null ||
!playlist ||
this.readyKeys().has(key) ||
this.unsupportedKeys.has(key) ||
this.isInErrorCooldown(key)
) {
return;
}
await (this.inflight.get(key) ?? this.runLoad(key, playlist));
}
/** Reloads the list while keeping the current cache served in the meantime. */
async refresh(playlist: PlaylistMeta | undefined): Promise<void> {
const key = this.keyFor(playlist);
if (key === null || !playlist) {
return;
}
const pending = this.inflight.get(key);
if (pending) {
return pending;
}
this.unsupportedKeys.delete(key);
this.errorCooldownUntil.delete(key);
await this.runLoad(key, playlist);
}
private keyFor(playlist: PlaylistMeta | undefined): string | null {
return playlist?._id ?? playlist?.portalUrl ?? null;
}
private versionSignalFor(key: string): WritableSignal<number> {
let versionSignal = this.versionSignals.get(key);
if (!versionSignal) {
versionSignal = signal(0);
this.versionSignals.set(key, versionSignal);
}
return versionSignal;
}
private isInErrorCooldown(key: string): boolean {
const until = this.errorCooldownUntil.get(key);
if (until === undefined) {
return false;
}
if (Date.now() >= until) {
this.errorCooldownUntil.delete(key);
return false;
}
return true;
}
private runLoad(key: string, playlist: PlaylistMeta): Promise<void> {
const task = (async () => {
this.patchKeySet(this.loadingKeys, key, true);
try {
const outcome = await loadFullItvChannelList(
this.requestDeps,
playlist,
(loaded, total) => this.reportProgress(key, loaded, total),
this.logger
);
if (Array.isArray(outcome)) {
this.channelsByKey.set(key, outcome);
this.errorCooldownUntil.delete(key);
this.patchKeySet(this.readyKeys, key, true);
this.versionSignalFor(key).update((version) => version + 1);
} else if (outcome === 'error') {
this.errorCooldownUntil.set(
key,
Date.now() + ERROR_COOLDOWN_MS
);
} else if (!this.readyKeys().has(key)) {
this.unsupportedKeys.add(key);
}
} finally {
this.inflight.delete(key);
this.patchKeySet(this.loadingKeys, key, false);
this.progressByKey.update((progress) => {
const next = { ...progress };
delete next[key];
return next;
});
}
})();
this.inflight.set(key, task);
return task;
}
private reportProgress(key: string, loaded: number, total: number): void {
this.progressByKey.update((progress) => ({
...progress,
[key]: { loaded, total },
}));
}
private patchKeySet(
target: ReturnType<typeof signal<ReadonlySet<string>>>,
key: string,
present: boolean
): void {
target.update((current) => {
const next = new Set(current);
if (present) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
}
}
@@ -0,0 +1,283 @@
import {
PlaylistMeta,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerItvChannel } from './models';
import {
StalkerRequestDeps,
executeStalkerRequest,
toStalkerContentItem,
toStalkerItvChannel,
} from './stores/utils';
export interface StalkerItvLoadProgress {
loaded: number;
total: number;
}
/**
* 'unsupported' — the portal answered but cannot serve a full list; the caller
* should keep the legacy paged flow. 'error' — a transient failure (network,
* timeout, a page that failed both attempts) that is worth retrying later.
*/
export type StalkerItvLoadOutcome =
| StalkerItvChannel[]
| 'unsupported'
| 'error';
export interface StalkerItvLoadLogger {
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
}
interface StalkerChannelListResponse {
js?: {
data?: unknown;
total_items?: number | string;
max_page_items?: number | string;
};
}
const FALLBACK_PAGE_SIZE = 14;
const CRAWL_CONCURRENCY = 4;
/** Hard cap so a misbehaving portal cannot make the crawl run forever. */
const MAX_CHANNELS = 30_000;
function toPositiveInt(value: unknown): number | null {
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
/**
* Loads the complete ITV channel list for a portal. First tries the Ministra
* `get_all_channels` action (all channels in one response — the same call STB
* clients use); if that isn't usable, crawls `get_ordered_list` pages. Stateless
* — the {@link StalkerItvCacheService} owns caching, progress state, and retry
* throttling.
*/
export async function loadFullItvChannelList(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
onProgress: (loaded: number, total: number) => void,
logger: StalkerItvLoadLogger
): Promise<StalkerItvLoadOutcome> {
const viaAllChannels = await tryGetAllChannels(deps, playlist, logger);
if (Array.isArray(viaAllChannels)) {
return viaAllChannels;
}
const crawled = await crawlOrderedPages(deps, playlist, onProgress, logger);
if (Array.isArray(crawled)) {
return crawled;
}
return viaAllChannels === 'error' || crawled === 'error'
? 'error'
: 'unsupported';
}
async function tryGetAllChannels(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
logger: StalkerItvLoadLogger
): Promise<StalkerItvLoadOutcome> {
try {
const response =
await executeStalkerRequest<StalkerChannelListResponse>(
deps,
playlist,
{
action: StalkerPortalActions.GetAllChannels,
type: 'itv',
}
);
const data = response?.js?.data;
if (!Array.isArray(data) || data.length === 0) {
logger.info(
'get_all_channels not usable, falling back to page crawl'
);
return 'unsupported';
}
return mapChannels(data, playlist);
} catch (error) {
logger.warn('get_all_channels failed', error);
return 'error';
}
}
async function crawlOrderedPages(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
onProgress: (loaded: number, total: number) => void,
logger: StalkerItvLoadLogger
): Promise<StalkerItvLoadOutcome> {
const firstResponse = await fetchOrderedPageWithRetry(deps, playlist, 1, logger);
if (firstResponse === null) {
return 'error';
}
const firstPage = Array.isArray(firstResponse.js?.data)
? firstResponse.js.data
: null;
if (firstPage === null || firstPage.length === 0) {
return 'unsupported';
}
const totalItems = Math.min(
toPositiveInt(firstResponse?.js?.total_items) ?? firstPage.length,
MAX_CHANNELS
);
const pageSize =
toPositiveInt(firstResponse?.js?.max_page_items) ??
(firstPage.length || FALLBACK_PAGE_SIZE);
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const items: unknown[] = [];
const seen = new Set<string>();
collectUnique(firstPage, items, seen);
onProgress(items.length, totalItems);
for (
let page = 2;
page <= totalPages && items.length < totalItems;
page += CRAWL_CONCURRENCY
) {
const batch: number[] = [];
for (
let next = page;
next < page + CRAWL_CONCURRENCY && next <= totalPages;
next++
) {
batch.push(next);
}
const results = await Promise.all(
batch.map((pageNumber) =>
fetchOrderedPageWithRetry(deps, playlist, pageNumber, logger)
)
);
let reachedEnd = false;
for (const pageResponse of results) {
const pageItems = Array.isArray(pageResponse?.js?.data)
? pageResponse.js.data
: null;
if (pageItems === null) {
logger.warn('Aborting ITV page crawl after failures');
return 'error';
}
const added = collectUnique(pageItems, items, seen);
if (added === 0) {
// Empty page, or a portal that ignores `p` and keeps returning
// already-seen channels — treat the collected list as complete
// instead of duplicating rows forever.
reachedEnd = true;
break;
}
}
onProgress(Math.min(items.length, totalItems), totalItems);
if (reachedEnd) {
break;
}
}
return mapChannels(items, playlist);
}
/**
* Appends only channels whose id hasn't been seen yet. Returns how many new
* channels were added so the crawl can stop when a page contributes nothing new.
*/
function collectUnique(
rawItems: unknown[],
target: unknown[],
seen: Set<string>
): number {
let added = 0;
for (const item of rawItems) {
const id = rawChannelId(item);
if (id === null || seen.has(id)) {
continue;
}
seen.add(id);
target.push(item);
added += 1;
}
return added;
}
function rawChannelId(item: unknown): string | null {
if (!item || typeof item !== 'object') {
return null;
}
const source = item as { id?: unknown; stream_id?: unknown };
const raw = source.id ?? source.stream_id;
return raw === undefined || raw === null || raw === '' ? null : String(raw);
}
async function fetchOrderedPageWithRetry(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
page: number,
logger: StalkerItvLoadLogger
): Promise<StalkerChannelListResponse | null> {
const firstAttempt = await fetchOrderedPage(deps, playlist, page, logger);
if (firstAttempt !== null) {
return firstAttempt;
}
return fetchOrderedPage(deps, playlist, page, logger);
}
async function fetchOrderedPage(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
page: number,
logger: StalkerItvLoadLogger
): Promise<StalkerChannelListResponse | null> {
try {
return await executeStalkerRequest<StalkerChannelListResponse>(
deps,
playlist,
{
action: StalkerPortalActions.GetOrderedList,
type: 'itv',
category: '*',
genre: '*',
sortby: 'number',
p: page,
}
);
} catch (error) {
logger.warn(`Failed to fetch ITV page ${page}`, error);
return null;
}
}
function mapChannels(
items: unknown[],
playlist: PlaylistMeta
): StalkerItvChannel[] {
const mapped: StalkerItvChannel[] = [];
const seen = new Set<string>();
for (const item of items) {
const channel = toStalkerItvChannel(
toStalkerContentItem(
item as Parameters<typeof toStalkerContentItem>[0],
playlist.portalUrl ?? ''
)
);
// Duplicate channel ids collide with the template's `track item.id`
// (get_all_channels can repeat, and some crawl paths overlap).
const id = String(channel.id ?? '');
if (id !== '' && seen.has(id)) {
continue;
}
if (id !== '') {
seen.add(id);
}
mapped.push(channel);
}
return mapped;
}
@@ -78,6 +78,44 @@ describe('stalker-series.adapters', () => {
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('derives missing VOD-series season numbers from natural season order', () => {
const mapped = mapVodSeriesEpisodes([
{
id: 'season-2',
video_id: 'v1',
name: 'Season 2',
season_number: '',
episodes: [
{
id: 'episode-2',
series_number: 1,
name: 'Second season pilot',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-1',
video_id: 'v1',
name: 'Season 1',
season_number: '',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
expect(mapped['Season 1'][0].season).toBe(1);
expect(mapped['Season 2'][0].season).toBe(2);
});
it('maps embedded series payload into regular season episodes', () => {
const regularSeasons = mapRegularSeriesSeasons(
{
@@ -7,6 +7,11 @@ import {
} from './models';
import { isStalkerSeriesFlag } from './stalker-vod.utils';
const naturalSeasonCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export interface VodSeriesSeasonVm {
id: string;
video_id: string;
@@ -154,7 +159,7 @@ export function mapVodSeriesEpisodes(
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const seasonNum = toEpisodeNumber(seasonKey) || 1;
const seasonNum = getVodSeriesSeasonNumber(season, seasons);
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
const episodeNum =
@@ -230,3 +235,24 @@ export function mapRegularSeriesEpisodes(
export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string {
return season.season_number || season.name || season.id;
}
export function getVodSeriesSeasonNumber(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): number {
const parsedSeasonNumber = Number(season.season_number);
if (Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0) {
return parsedSeasonNumber;
}
const orderedSeasons = [...seasons].sort((seasonA, seasonB) =>
naturalSeasonCollator.compare(
getVodSeriesSeasonKey(seasonA),
getVodSeriesSeasonKey(seasonB)
)
);
const seasonIndex = orderedSeasons.findIndex(
(candidate) => candidate.id === season.id
);
return seasonIndex >= 0 ? seasonIndex + 1 : 1;
}
@@ -24,7 +24,8 @@ type GetProfileWithIdentity = (
) => Promise<StalkerProfileResponse>;
describe('StalkerSessionService identity payloads', () => {
const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php';
const portalUrl =
'https://portal.example.com/stalker_portal/server/load.php';
const macAddress = '00:1A:79:AA:BB:CC';
let service: StalkerSessionService;
@@ -56,6 +57,10 @@ describe('StalkerSessionService identity payloads', () => {
service = TestBed.inject(StalkerSessionService);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => {
const getProfile =
service.getProfile as unknown as GetProfileWithIdentity;
@@ -180,6 +185,34 @@ describe('StalkerSessionService identity payloads', () => {
expect(handshakePayload.serialNumber).toBe('CUSTOMSN123');
});
it('does not log credentials from portal request errors', async () => {
const token = 'stalker-error-token-secret';
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
dataService.sendIpcEvent.mockRejectedValue(
new Error(
`Request failed: https://portal.example/api?token=${token}&action=get_profile`
)
);
await expect(
service.getProfile(portalUrl, macAddress, token, {}, 'random-1')
).rejects.toThrow('Request failed');
const output = consoleError.mock.calls
.flatMap((call) =>
call.map((value) =>
value instanceof Error
? `${value.message}\n${value.stack ?? ''}`
: JSON.stringify(value)
)
)
.join('\n');
expect(output).not.toContain(token);
expect(output).toContain('get_profile');
});
function lastStalkerPayload(): {
params: Record<string, unknown>;
serialNumber?: string;
@@ -1,10 +1,7 @@
import { Injectable, inject } from '@angular/core';
import {
createDevLogger,
Playlist,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { DataService } from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
getStalkerPortalIdentityFromPlaylist,
LEGACY_DEFAULT_STALKER_SERIAL,
@@ -91,7 +88,7 @@ interface StalkerAuthConfirmationResponse {
})
export class StalkerSessionService {
private dataService = inject(DataService);
private readonly debugLog = createDevLogger('StalkerSession');
private readonly logger = createLogger('StalkerSession');
// In-memory token cache for current session (keyed by playlist ID)
private tokenCache = new Map<string, string>();
@@ -234,7 +231,7 @@ export class StalkerSessionService {
);
} catch (error) {
// Keep failures non-fatal; next interval can recover after token refresh.
console.warn('[StalkerSession] Watchdog ping failed:', error);
this.logger.warn('Watchdog ping failed:', error);
} finally {
this.watchdogInFlight.delete(playlistId);
}
@@ -281,10 +278,10 @@ export class StalkerSessionService {
};
}
console.error('[StalkerSession] No token in response');
this.logger.error('No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
console.error('[StalkerSession] Handshake error:', error);
this.logger.error('Handshake error:', error);
throw error;
}
}
@@ -364,7 +361,7 @@ export class StalkerSessionService {
return response;
} catch (error) {
console.error('[StalkerSession] Get profile error:', error);
this.logger.error('Get profile error:', error);
throw error;
}
}
@@ -404,7 +401,7 @@ export class StalkerSessionService {
return false;
} catch (error) {
console.error('[StalkerSession] do_auth error:', error);
this.logger.error('do_auth error:', error);
throw error;
}
}
@@ -447,7 +444,7 @@ export class StalkerSessionService {
profileResponse.js.msg ||
profileResponse.js.block_msg ||
'Unknown profile error';
console.error('[StalkerSession] Profile error:', errorMsg);
this.logger.error('Profile error:', errorMsg);
throw new Error(`Profile error: ${errorMsg}`);
}
@@ -457,7 +454,7 @@ export class StalkerSessionService {
};
} catch (error) {
// Profile fetch failed - this is a real error, propagate it
console.error('[StalkerSession] Profile fetch failed:', error);
this.logger.error('Profile fetch failed:', error);
throw error;
}
}
@@ -487,14 +484,14 @@ export class StalkerSessionService {
// This prevents race conditions when multiple resources request a token simultaneously
const pendingPromise = this.pendingAuth.get(playlist._id);
if (pendingPromise) {
this.debugLog('Waiting for pending authentication...');
this.logger.debug('Waiting for pending authentication...');
return pendingPromise;
}
// No cached token - need to do full authentication (handshake + get_profile)
// Don't trust stored tokens as they may be from a different session
if (!playlist.portalUrl || !playlist.macAddress) {
console.error('[StalkerSession] Missing portal URL or MAC address');
this.logger.error('Missing portal URL or MAC address');
throw new Error('Portal URL and MAC address are required');
}
const portalUrl = playlist.portalUrl;
@@ -245,6 +245,13 @@ describe('stalker-vod.utils regressions', () => {
rating_imdb: '',
rating_kinopoisk: '8.1',
tmdb_cast: tmdbCast,
tmdb_directors: [
{
name: 'Eric Kripke',
profileUrl: null,
tmdbPersonId: 1216630,
},
],
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg',
tmdb_trailer: 'abc123def',
tmdb_recommendations: tmdbRecommendations,
@@ -252,6 +259,7 @@ describe('stalker-vod.utils regressions', () => {
});
expect(info.tmdb_cast).toEqual(tmdbCast);
expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke');
expect(info.tmdb_backdrop).toBe(
'https://image.tmdb.org/t/p/w1280/boys.jpg'
);
@@ -158,6 +158,9 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
// views normalize the selected item on every render
...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}),
...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}),
...(info.tmdb_directors
? { tmdb_directors: info.tmdb_directors }
: {}),
...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}),
...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}),
...(info.tmdb_recommendations
@@ -1,8 +1,11 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import { StalkerItvChannel } from '../../models';
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerContent } from './with-stalker-content.feature';
@@ -101,6 +104,29 @@ async function waitForCondition(
throw new Error('Timed out waiting for resource activity');
}
/**
* Controllable stand-in for StalkerItvCacheService so the legacy paged flow
* can be tested in isolation and the cache-served flow deterministically.
*/
function createItvCacheMock(initialChannels: StalkerItvChannel[] | null = null) {
const version = signal(0);
let channels = initialChannels;
return {
versionFor: jest.fn(() => version()),
getChannels: jest.fn(() => channels),
ensureLoaded: jest.fn().mockResolvedValue(undefined),
refresh: jest.fn().mockResolvedValue(undefined),
isReady: jest.fn(() => channels !== null),
isLoading: jest.fn(() => false),
progressOf: jest.fn(() => null),
setChannels(next: StalkerItvChannel[] | null) {
channels = next;
version.update((value) => value + 1);
},
};
}
describe('withStalkerContent failure states', () => {
let store: InstanceType<typeof TestContentStore>;
let dataService: {
@@ -116,6 +142,10 @@ describe('withStalkerContent failure states', () => {
providers: [
TestContentStore,
{ provide: DataService, useValue: dataService },
{
provide: StalkerItvCacheService,
useValue: createItvCacheMock(),
},
{
provide: StalkerSessionService,
useValue: {
@@ -461,3 +491,266 @@ describe('withStalkerContent failure states', () => {
expect(store.getPaginatedContent()[0]?.name).toBe('Page 1 restored');
});
});
describe('withStalkerContent full ITV channel list cache', () => {
const CACHED_CHANNELS: StalkerItvChannel[] = [
{ id: '1', cmd: 'ffrt http://x/1', name: 'News One', tv_genre_id: '5' },
{ id: '2', cmd: 'ffrt http://x/2', name: 'Sports HD', tv_genre_id: '9' },
{ id: '3', cmd: 'ffrt http://x/3', name: 'News Two', tv_genre_id: '5' },
];
let store: InstanceType<typeof TestContentStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let itvCache: ReturnType<typeof createItvCacheMock>;
function setup(initialChannels: StalkerItvChannel[] | null) {
dataService = {
sendIpcEvent: jest.fn(),
};
itvCache = createItvCacheMock(initialChannels);
TestBed.configureTestingModule({
providers: [
TestContentStore,
{ provide: DataService, useValue: dataService },
{ provide: StalkerItvCacheService, useValue: itvCache },
{
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
store = TestBed.inject(TestContentStore);
}
function enterItvCategory(categoryId: string) {
store.setSelectedContentType('itv');
store.setCategories('itv', [
{ category_id: '5', category_name: 'News' },
{ category_id: '9', category_name: 'Sports' },
]);
store.setSelectedCategory(categoryId);
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
}
it('serves the whole category from the cache without portal requests', async () => {
setup(CACHED_CHANNELS);
enterItvCategory('5');
await waitForCondition(() => store.itvChannels().length === 2);
expect(store.itvChannels().map((channel) => channel.name)).toEqual([
'News One',
'News Two',
]);
expect(store.totalCount()).toBe(2);
expect(store.hasMoreChannels()).toBe(false);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('serves all channels for the "*" category so search can cover everything', async () => {
setup(CACHED_CHANNELS);
enterItvCategory('*');
await waitForCondition(() => store.itvChannels().length === 3);
// Regression for the "search only finds the first 14 loaded items"
// bug: the full list is available at once, no paging required.
expect(store.itvChannels()).toHaveLength(3);
expect(store.hasMoreChannels()).toBe(false);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('falls back to the legacy paged fetch for a genre absent from the cache (censored/adult)', async () => {
setup(CACHED_CHANNELS);
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
{
id: 'adult-1',
name: 'Adult One',
cmd: 'ffrt http://x/adult-1',
},
],
total_items: 40,
},
});
// Genre '19' has no channels in the cached full list.
store.setSelectedContentType('itv');
store.setCategories('itv', [
{ category_id: '5', category_name: 'News' },
{ category_id: '19', category_name: 'For adults', censored: true },
]);
store.setSelectedCategory('19');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.itvChannels().length === 1);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.GetOrderedList,
type: 'itv',
genre: '19',
p: 1,
}),
})
);
expect(store.itvChannels()[0]?.name).toBe('Adult One');
// Portal pagination stays in charge for this genre.
expect(store.hasMoreChannels()).toBe(true);
});
it('reports whether the selected category is served from the cache', () => {
setup(CACHED_CHANNELS);
enterItvCategory('5');
expect(store.itvSelectedCategoryFromCache()).toBe(true);
store.setSelectedCategory('*');
expect(store.itvSelectedCategoryFromCache()).toBe(true);
// Genre with zero cached channels — e.g. a censored/adult category.
store.setSelectedCategory('19');
expect(store.itvSelectedCategoryFromCache()).toBe(false);
});
it('omits genres without cached channels from the count map (adult genres)', () => {
setup(CACHED_CHANNELS);
store.setSelectedContentType('itv');
store.setCategories('itv', [
{ category_id: '*', category_name: 'All' },
{ category_id: '5', category_name: 'News' },
{ category_id: '19', category_name: 'For adults', censored: true },
]);
store.setCurrentPlaylist(PLAYLIST);
const counts = store.itvCategoryItemCounts();
expect(counts.get(5)).toBe(2);
// Adult genres are excluded from get_all_channels — regardless of any
// `censored` flag — so their count is unknown: no entry, no badge.
expect(counts.has(19)).toBe(false);
});
it('preloadItvChannels kicks off the cache load for the current playlist', () => {
setup(null);
store.setCurrentPlaylist(PLAYLIST);
store.preloadItvChannels();
expect(itvCache.ensureLoaded).toHaveBeenCalledWith(PLAYLIST);
});
it('exposes per-genre channel counts for category badges (with the total under the "All" key)', () => {
setup(CACHED_CHANNELS);
enterItvCategory('*');
const counts = store.itvCategoryItemCounts();
// Two channels in genre 5, one in genre 9.
expect(counts.get(5)).toBe(2);
expect(counts.get(9)).toBe(1);
// "All" category is category_id '*' → Number('*') is NaN; the grand
// total lives under the NaN key so the "All" row shows every channel.
expect(counts.get(Number.NaN)).toBe(3);
});
it('has an empty count map when no full list is cached', () => {
setup(null);
enterItvCategory('*');
const counts = store.itvCategoryItemCounts();
// No genre entries at all — counts are unknown without the cache.
expect(counts.get(5)).toBeUndefined();
expect(counts.get(Number.NaN)).toBe(0);
});
it('drops a stale legacy page response that resolves after the cache took over', async () => {
setup(null);
const legacyResponse = createDeferred<unknown>();
dataService.sendIpcEvent.mockReturnValue(legacyResponse.promise);
enterItvCategory('5');
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
// The background full-list load finishes while the legacy page-1
// request is still in flight; the resource re-fires and serves the
// complete category from the cache.
itvCache.setChannels(CACHED_CHANNELS);
await waitForCondition(() => store.itvChannels().length === 2);
// The late legacy response must not overwrite the full list with the
// first 14-item page again.
legacyResponse.resolve({
js: {
data: [
{
id: 'legacy-1',
name: 'Legacy page one',
cmd: 'ffrt http://x/legacy',
},
],
total_items: 28,
},
});
await flushResources();
expect(store.itvChannels().map((channel) => channel.name)).toEqual([
'News One',
'News Two',
]);
expect(store.hasMoreChannels()).toBe(false);
});
it('kicks off a background full-list load and swaps it in once ready', async () => {
setup(null);
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
{
id: 'legacy-1',
name: 'Legacy page one',
cmd: 'ffrt http://x/legacy',
},
],
total_items: 28,
},
});
enterItvCategory('5');
await waitForCondition(() => store.itvChannels().length === 1);
// Legacy paged flow stays in charge while the cache is cold.
expect(store.itvChannels()[0]?.name).toBe('Legacy page one');
expect(store.hasMoreChannels()).toBe(true);
expect(itvCache.ensureLoaded).toHaveBeenCalledWith(PLAYLIST);
// Full list finished loading in the background.
itvCache.setChannels(CACHED_CHANNELS);
await waitForCondition(() => store.itvChannels().length === 2);
expect(store.itvChannels().map((channel) => channel.name)).toEqual([
'News One',
'News Two',
]);
expect(store.hasMoreChannels()).toBe(false);
});
});
@@ -17,6 +17,7 @@ import {
StalkerVodSource,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
import { StalkerSessionService } from '../../stalker-session.service';
import {
ResourceState,
@@ -26,6 +27,7 @@ import {
} from '../stalker-store.contracts';
import {
executeStalkerRequest,
filterItvChannelsByGenre,
toStalkerContentItem,
toStalkerItvChannel,
} from '../utils';
@@ -64,6 +66,7 @@ const initialContentState: StalkerContentState = {
interface StalkerCategoryResponseItem {
id?: string | number;
title?: string;
censored?: string | number;
}
interface StalkerCategoryResponse {
@@ -191,7 +194,8 @@ export function withStalkerContent() {
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
translateService = inject(TranslateService)
translateService = inject(TranslateService),
itvCache = inject(StalkerItvCacheService)
) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
@@ -269,6 +273,9 @@ export function withStalkerContent() {
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
censored:
item.censored === 1 ||
item.censored === '1',
})
);
const categories = prependAllCategory(
@@ -325,6 +332,17 @@ export function withStalkerContent() {
search: storeContext.searchPhrase(),
pageIndex: storeContext.page() + 1,
currentPlaylist: storeContext.currentPlaylist(),
// Re-fires the loader once THIS portal's full ITV
// channel list finishes loading or is refreshed.
// Read only for ITV and scoped per-portal so a
// different portal's (or a radio/vod) load never
// re-fires and re-appends this resource's page.
itvCacheVersion:
storeContext.selectedContentType() === 'itv'
? itvCache.versionFor(
storeContext.currentPlaylist()
)
: 0,
availableCategoryCount: getCategoriesByType(
store,
storeContext.selectedContentType()
@@ -376,6 +394,45 @@ export function withStalkerContent() {
}
const categoryParam = params.category || '*';
if (params.contentType === 'itv') {
const cachedChannels =
itvCache.getChannels(playlist);
const channels =
cachedChannels !== null
? filterItvChannelsByGenre(
cachedChannels,
categoryParam
)
: null;
// Serve from the cache only when it actually
// has channels for this genre. Censored (adult)
// genres are typically EXCLUDED from
// get_all_channels by the portal, so an empty
// filter result falls through to the legacy
// paged fetch, which still returns them.
if (
channels !== null &&
(categoryParam === '*' ||
channels.length > 0)
) {
patchState(store, {
totalCount: channels.length,
paginatedContent: channels,
itvChannels: channels,
hasMoreChannels: false,
contentError: null,
});
return channels;
}
// Full-list load runs in the background; the
// resource re-fires via `itvCacheVersion` once
// the cache is ready. Until then the legacy
// paged flow below serves the first pages.
void itvCache.ensureLoaded(playlist);
}
const paramsPlaylistKey =
params.currentPlaylist?._id ??
params.currentPlaylist?.portalUrl ??
@@ -397,7 +454,18 @@ export function withStalkerContent() {
storeContext.searchPhrase() &&
params.pageIndex ===
storeContext.page() + 1 &&
paramsPlaylistKey === currentPlaylistKey
paramsPlaylistKey === currentPlaylistKey &&
// A legacy paged response must not overwrite
// the full cached list that a re-fired
// loader served in the meantime. Scoped
// per-portal and to ITV so another portal's
// load never invalidates this response.
params.itvCacheVersion ===
(params.contentType === 'itv'
? itvCache.versionFor(
currentPlaylist
)
: 0)
);
};
const queryParams: Record<string, string | number> =
@@ -535,8 +603,94 @@ export function withStalkerContent() {
withComputed((store) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const itvCache = inject(StalkerItvCacheService);
/**
* The whole portal's ITV channel list (all categories) when
* cached. `versionFor` establishes the reactive dependency so this
* recomputes when the list becomes ready or is refreshed.
*/
const itvFullChannelList = computed(() => {
const playlist = storeContext.currentPlaylist();
itvCache.versionFor(playlist);
return itvCache.getChannels(playlist) ?? [];
});
/**
* Per-genre channel counts for ITV category badges, keyed by
* numeric `tv_genre_id` (mirrors the Xtream count map). Only
* populated when the full list is cached. The "All" pseudo
* category has id `'*'` → `Number('*')` is NaN, and a Map keys
* NaN by SameValueZero, so the grand total under `NaN` makes the
* "All" row show every channel. Genres with NO cached channels
* get no entry at all: adult genres are excluded from
* `get_all_channels` (with or without a `censored` flag from
* `get_genres`), so their real count is unknown and the badge
* is omitted rather than showing a misleading "0".
*/
const itvCategoryItemCounts = computed(() => {
const counts = new Map<number, number>();
const channels = itvFullChannelList();
for (const channel of channels) {
const genreId = Number(channel.tv_genre_id);
if (!Number.isNaN(genreId)) {
counts.set(genreId, (counts.get(genreId) ?? 0) + 1);
}
}
counts.set(Number.NaN, channels.length);
return counts;
});
return {
/** True when the complete ITV channel list is cached, so local search covers all channels. */
itvFullListActive: computed(() =>
itvCache.isReady(storeContext.currentPlaylist())
),
itvFullListLoading: computed(() =>
itvCache.isLoading(storeContext.currentPlaylist())
),
itvFullListProgress: computed(() =>
itvCache.progressOf(storeContext.currentPlaylist())
),
/** Exposes the whole portal's ITV channel list so search can span every channel. */
itvFullChannelList,
/**
* True when the currently selected ITV category can be served
* from the full-list cache. Censored (adult) genres are usually
* excluded from `get_all_channels`, so a genre with zero cached
* channels stays on the legacy paged flow. O(1): reuses the
* memoized per-genre count map instead of re-scanning the list
* on every category click.
*/
itvSelectedCategoryFromCache: computed(() => {
const playlist = storeContext.currentPlaylist();
if (!itvCache.isReady(playlist)) {
return false;
}
const categoryId = storeContext.selectedCategoryId();
if (!categoryId) {
return false;
}
if (categoryId === '*') {
return true;
}
const genreId = Number(categoryId);
if (Number.isNaN(genreId)) {
// Non-numeric genre id would collide with the NaN
// "All" total key — fall back to the direct scan.
return (
filterItvChannelsByGenre(
itvFullChannelList(),
categoryId
).length > 0
);
}
return (itvCategoryItemCounts().get(genreId) ?? 0) > 0;
}),
itvCategoryItemCounts,
getTotalPages: computed(() =>
Math.ceil(store.totalCount() / storeContext.limit())
),
@@ -600,7 +754,25 @@ export function withStalkerContent() {
isCategoryResourceFailed: computed(() => store.categoryError()),
};
}),
withMethods((store) => ({
withMethods((store) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const itvCache = inject(StalkerItvCacheService);
return {
/**
* Kicks off the full ITV channel list load as soon as the Live TV
* section is entered (instead of waiting for the first category
* click), so the all-channels view and category count badges are
* available immediately. Safe to call repeatedly — the cache
* de-duplicates in-flight loads and memoizes unsupported portals.
*/
preloadItvChannels(): void {
void itvCache.ensureLoaded(storeContext.currentPlaylist());
},
async refreshItvChannels(): Promise<void> {
await itvCache.refresh(storeContext.currentPlaylist());
},
setCategories(
type: StalkerContentType,
categories: StalkerCategoryItem[]
@@ -622,6 +794,7 @@ export function withStalkerContent() {
setRadioChannels(channels: StalkerItvChannel[]) {
patchState(store, { radioChannels: channels });
},
}))
};
})
);
}
@@ -1,6 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { EpgItem, Playlist } from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerEpg } from './with-stalker-epg.feature';
@@ -42,6 +43,11 @@ describe('withStalkerEpg', () => {
let stalkerSessionService: {
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, unknown[]>;
};
let epgBridge: {
supportsEpgMapping: boolean;
getEpgMappingsBatch: jest.Mock<Promise<unknown>, unknown[]>;
getChannelPrograms: jest.Mock<Promise<unknown>, unknown[]>;
};
beforeEach(() => {
runtimeSupportsEpg = true;
@@ -51,6 +57,11 @@ describe('withStalkerEpg', () => {
stalkerSessionService = {
makeAuthenticatedRequest: jest.fn(),
};
epgBridge = {
supportsEpgMapping: true,
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
getChannelPrograms: jest.fn().mockResolvedValue(null),
};
TestBed.configureTestingModule({
providers: [
@@ -68,6 +79,10 @@ describe('withStalkerEpg', () => {
provide: StalkerSessionService,
useValue: stalkerSessionService,
},
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
],
});
@@ -176,6 +191,80 @@ describe('withStalkerEpg', () => {
expect(store.selectedItvEpgPrograms()).toEqual([]);
expect(store.isLoadingBulkItvEpg()).toBe(false);
});
describe('applyMappedItvEpg', () => {
const MAPPED_PROGRAM = {
channel: 'mapped.channel.id',
title: 'Mapped Show',
desc: 'From uploaded XMLTV',
start: '2026-07-11T10:00:00.000Z',
stop: '2026-07-11T11:00:00.000Z',
};
it('overlays uploaded XMLTV programs for mapped channels', async () => {
epgBridge.getEpgMappingsBatch.mockResolvedValue({
'stalker:playlist-1:10001': 'mapped.channel.id',
});
epgBridge.getChannelPrograms.mockResolvedValue([MAPPED_PROGRAM]);
await store.applyMappedItvEpg(['10001', '10002']);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledWith([
'stalker:playlist-1:10001',
'stalker:playlist-1:10002',
]);
expect(store.bulkItvEpgByChannel()['10001']).toEqual([
{ ...MAPPED_PROGRAM, channel: '10001' },
]);
expect(store.selectedItvEpgPrograms()).toEqual([
{ ...MAPPED_PROGRAM, channel: '10001' },
]);
});
it('checks each channel id only once per playlist session', async () => {
epgBridge.getEpgMappingsBatch.mockResolvedValue({});
await store.applyMappedItvEpg(['10001']);
await store.applyMappedItvEpg(['10001']);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
});
it('keeps overrides when ensureBulkItvEpg replaces the bulk record', async () => {
epgBridge.getEpgMappingsBatch.mockResolvedValue({
'stalker:playlist-1:10001': 'mapped.channel.id',
});
epgBridge.getChannelPrograms.mockResolvedValue([MAPPED_PROGRAM]);
await store.applyMappedItvEpg(['10001']);
dataService.sendIpcEvent.mockResolvedValue({
js: {
'10002': [
{
name: 'Portal Show',
start_timestamp: 1_752_220_800,
stop_timestamp: 1_752_224_400,
},
],
},
});
await store.ensureBulkItvEpg(168);
const record = store.bulkItvEpgByChannel();
expect(record['10001']).toEqual([
{ ...MAPPED_PROGRAM, channel: '10001' },
]);
expect(record['10002']?.length).toBeGreaterThan(0);
});
it('does nothing when the mapping bridge is unsupported', async () => {
epgBridge.supportsEpgMapping = false;
await store.applyMappedItvEpg(['10001']);
expect(epgBridge.getEpgMappingsBatch).not.toHaveBeenCalled();
});
});
});
function buildEntry(
@@ -6,9 +6,11 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
buildStalkerEpgMappingKey,
EpgItem,
EpgProgram,
StalkerPortalActions,
@@ -99,7 +101,8 @@ export function withStalkerEpg() {
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
runtime = inject(RuntimeCapabilitiesService)
runtime = inject(RuntimeCapabilitiesService),
epgBridge = inject(EpgRuntimeBridgeService)
) => {
const storeContext = store as typeof store &
StalkerEpgFeatureStoreContract;
@@ -109,6 +112,31 @@ export function withStalkerEpg() {
};
const supportsEpg = (): boolean => runtime.supportsEpg;
// Manual EPG mapping overrides (uploaded XMLTV programs keyed
// by the normalized Stalker channel id). Kept outside the
// signal state so ensureBulkItvEpg can merge them back in
// whenever it replaces the bulk record.
const mappingOverridesById = new Map<string, EpgProgram[]>();
const mappingCheckedIds = new Set<string>();
let mappingPlaylistId: string | null = null;
const resetMappingOverrides = (): void => {
mappingOverridesById.clear();
mappingCheckedIds.clear();
mappingPlaylistId = null;
};
const mappingOverridesRecord = (): Record<
string,
EpgProgram[]
> => {
const record: Record<string, EpgProgram[]> = {};
for (const [id, programs] of mappingOverridesById) {
record[id] = programs;
}
return record;
};
const requestEpg = async (
playlist: NonNullable<
ReturnType<
@@ -229,21 +257,151 @@ export function withStalkerEpg() {
);
patchState(store, {
bulkItvEpgByChannel: bulkPrograms,
bulkItvEpgByChannel: {
...bulkPrograms,
...mappingOverridesRecord(),
},
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
} catch (error) {
logger.warn('Bulk Stalker EPG unavailable', error);
patchState(store, {
bulkItvEpgByChannel: {},
bulkItvEpgByChannel: mappingOverridesRecord(),
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
}
},
/**
* Overlay manual EPG mappings for the given Stalker
* channel ids onto the bulk ITV record. Each id is
* checked at most once per playlist session; channels
* with a saved mapping get their programs from the
* uploaded XMLTV guide instead of the portal EPG, which
* feeds both the active EPG panel and the row previews.
*/
async applyMappedItvEpg(
channelIds: ReadonlyArray<string | number>
): Promise<void> {
if (!epgBridge.supportsEpgMapping || !supportsEpg()) {
return;
}
const playlist = storeContext.currentPlaylist();
if (!playlist?._id) {
return;
}
const playlistId = String(playlist._id);
if (mappingPlaylistId !== playlistId) {
resetMappingOverrides();
mappingPlaylistId = playlistId;
}
const freshIds = [
...new Set(
channelIds
.map((id) => normalizeStalkerEntityId(id))
.filter(
(id) =>
id && !mappingCheckedIds.has(id)
)
),
];
if (freshIds.length === 0) {
return;
}
// The store is a root singleton, so an in-flight
// call can outlive a portal switch — bail out after
// every await instead of writing portal A's data
// into portal B's state.
const isStale = (): boolean =>
mappingPlaylistId !== playlistId ||
String(
storeContext.currentPlaylist()?._id ?? ''
) !== playlistId;
const keyById = new Map(
freshIds.map(
(id) =>
[
id,
buildStalkerEpgMappingKey(
playlistId,
id
),
] as const
)
);
let mappings: Record<string, string> | null = null;
try {
mappings = await epgBridge.getEpgMappingsBatch([
...keyById.values(),
]);
} catch (error) {
// Nothing is marked checked — the next
// invocation retries the lookup.
logger.warn(
'Stalker EPG mapping lookup failed',
error
);
return;
}
if (!mappings || isStale()) {
return;
}
let changed = false;
for (const [channelId, key] of keyById) {
const mappedEpgId = mappings[key]?.trim();
if (!mappedEpgId) {
// No mapping for this channel — a stable
// outcome, safe to dedupe.
mappingCheckedIds.add(channelId);
continue;
}
try {
const programs =
(await epgBridge.getChannelPrograms(
mappedEpgId
)) ?? [];
if (isStale()) {
return;
}
mappingCheckedIds.add(channelId);
if (programs.length === 0) {
continue;
}
mappingOverridesById.set(
channelId,
programs.map((program) => ({
...program,
channel: channelId,
}))
);
changed = true;
} catch {
// Transient failure — leave the id
// unchecked so a later call retries; the
// portal EPG stays in place meanwhile.
}
}
if (!changed || isStale()) {
return;
}
patchState(store, {
bulkItvEpgByChannel: {
...store.bulkItvEpgByChannel(),
...mappingOverridesRecord(),
},
});
},
clearBulkItvEpgCache(): void {
resetMappingOverrides();
patchState(store, initialEpgState);
},
};
@@ -0,0 +1,32 @@
import { StalkerItvChannel } from '../../models';
import { filterItvChannelsByGenre } from './stalker-content-mappers';
describe('filterItvChannelsByGenre', () => {
const CHANNELS: StalkerItvChannel[] = [
{ id: '1', cmd: 'ffrt http://x/1', name: 'News One', tv_genre_id: '5' },
{ id: '2', cmd: 'ffrt http://x/2', name: 'Sports HD', tv_genre_id: 9 },
{ id: '3', cmd: 'ffrt http://x/3', name: 'No Genre' },
];
it('returns all channels for the "*" category', () => {
expect(filterItvChannelsByGenre(CHANNELS, '*')).toHaveLength(3);
});
it('returns all channels when no category is selected', () => {
expect(filterItvChannelsByGenre(CHANNELS, null)).toHaveLength(3);
expect(filterItvChannelsByGenre(CHANNELS, undefined)).toHaveLength(3);
});
it('matches numeric and string genre ids', () => {
expect(
filterItvChannelsByGenre(CHANNELS, '5').map((item) => item.id)
).toEqual(['1']);
expect(
filterItvChannelsByGenre(CHANNELS, '9').map((item) => item.id)
).toEqual(['2']);
});
it('hides channels without a genre from specific categories', () => {
expect(filterItvChannelsByGenre(CHANNELS, '404')).toEqual([]);
});
});
@@ -61,6 +61,24 @@ export function toStalkerContentItem(
};
}
/**
* Local category filter for fully cached ITV channel lists.
* Mirrors the portal-side `genre=<id>` filter of `get_ordered_list`.
*/
export function filterItvChannelsByGenre(
channels: StalkerItvChannel[],
categoryId: string | null | undefined
): StalkerItvChannel[] {
if (!categoryId || categoryId === '*') {
return channels;
}
const target = String(categoryId);
return channels.filter(
(channel) => String(channel.tv_genre_id ?? '') === target
);
}
export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel {
return {
...item,
@@ -0,0 +1,72 @@
@use '../../../../../../ui/styles/panel-header' as panel;
:host {
align-self: stretch;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
width: 100%;
}
.category-content-header {
@include panel.standard-panel-header($sticky: true);
}
.category-meta {
@include panel.standard-panel-meta(8px);
}
.category-title {
@include panel.standard-panel-title(0.94rem);
color: var(--mat-sys-on-surface);
}
.category-subtitle {
@include panel.standard-panel-subtitle(
$font-size: 0.72rem,
$padding: 2px 8px,
$color: var(--mat-sys-on-surface-variant),
$background: var(--mat-sys-surface-container)
);
}
.all-items-progress {
align-items: center;
color: var(--mat-sys-on-surface-variant);
display: inline-flex;
font-size: 0.72rem;
gap: 6px;
&__count {
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
}
// Let the paginator inherit the panel header's tinted background instead of
// painting its own (mismatched) Material surface color.
mat-paginator {
background: transparent !important;
flex-shrink: 0;
--mat-paginator-container-size: 40px;
--mat-paginator-enabled-icon-color: var(
--app-body-color,
var(--mat-sys-on-surface-variant)
);
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
}
app-grid-list.all-items-grid {
--cover-grid-min-width: var(--live-channel-grid-min-width, 148px);
display: block;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 20px;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent)
transparent;
}
@@ -0,0 +1,123 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { StalkerItvChannel } from '@iptvnator/portal/stalker/data-access';
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
function buildChannels(count: number): StalkerItvChannel[] {
return Array.from({ length: count }, (_, index) => ({
id: `ch-${index}`,
cmd: `ffrt4://itv/${index}`,
name: index === count - 1 ? 'Needle TV' : `Channel ${index}`,
o_name: index === count - 1 ? 'Needle TV' : `Channel ${index}`,
logo: `logo-${index}.png`,
is_series: null,
}));
}
describe('StalkerItvAllItemsComponent', () => {
let fixture: ComponentFixture<StalkerItvAllItemsComponent>;
let component: StalkerItvAllItemsComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
StalkerItvAllItemsComponent,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(StalkerItvAllItemsComponent);
component = fixture.componentInstance;
});
it('renders the first client-side page of channels with a paginator', () => {
fixture.componentRef.setInput('channels', buildChannels(60));
fixture.detectChanges();
expect(component.pagedGridItems()).toHaveLength(25);
expect(fixture.nativeElement.querySelectorAll('mat-card')).toHaveLength(
25
);
expect(fixture.nativeElement.querySelector('mat-paginator')).toBeTruthy();
expect(
fixture.nativeElement
.querySelector('.category-subtitle')
?.textContent?.trim()
).toContain('60');
});
it('slices the next page on paginator change without touching the source', () => {
fixture.componentRef.setInput('channels', buildChannels(60));
fixture.detectChanges();
component.onPageChange({
pageIndex: 2,
pageSize: 25,
length: 60,
} as never);
fixture.detectChanges();
// Third page holds the remaining 10 channels.
expect(component.pagedGridItems()).toHaveLength(10);
expect(component.pagedGridItems()[0]['id']).toBe('ch-50');
});
it('filters by the search term across ALL channels and resets to page one', () => {
fixture.componentRef.setInput('channels', buildChannels(60));
fixture.detectChanges();
component.onPageChange({
pageIndex: 1,
pageSize: 25,
length: 60,
} as never);
fixture.componentRef.setInput('searchTerm', 'needle');
fixture.detectChanges();
expect(component.pageIndex()).toBe(0);
expect(
component.pagedGridItems().map((item) => item['name'])
).toEqual(['Needle TV']);
});
it('maps the stalker logo to stream_icon and drops null is_series for the grid', () => {
fixture.componentRef.setInput('channels', buildChannels(1));
fixture.detectChanges();
const [item] = component.pagedGridItems();
expect(item['stream_icon']).toBe('logo-0.png');
expect('is_series' in item).toBe(false);
});
it('emits channelActivated when a card is clicked', () => {
const activated = jest.fn();
fixture.componentRef.setInput('channels', buildChannels(3));
fixture.componentInstance.channelActivated.subscribe(activated);
fixture.detectChanges();
(
fixture.nativeElement.querySelector('mat-card') as HTMLElement
).click();
expect(activated).toHaveBeenCalledWith(
expect.objectContaining({ id: 'ch-0' })
);
});
it('shows skeletons and load progress while loading', () => {
fixture.componentRef.setInput('channels', []);
fixture.componentRef.setInput('loading', true);
fixture.componentRef.setInput('progress', { loaded: 140, total: 400 });
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.grid-skeleton-card')
).toBeTruthy();
expect(
fixture.nativeElement
.querySelector('.all-items-progress')
?.textContent?.replace(/\s+/g, '')
).toContain('140/400');
expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull();
});
});
@@ -0,0 +1,146 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
linkedSignal,
output,
signal,
} from '@angular/core';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
import {
StalkerItvChannel,
StalkerItvLoadProgress,
} from '@iptvnator/portal/stalker/data-access';
/**
* "All channels" grid shown in the Live TV main area before a category is
* selected — mirrors the Xtream live "All Items" view. Fed by the full ITV
* channel list cache; pagination is purely client-side so it never touches the
* store's legacy page state (which would re-fire portal requests).
*/
@Component({
selector: 'app-stalker-itv-all-items',
imports: [
GridListComponent,
MatPaginatorModule,
MatProgressSpinnerModule,
TranslatePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="category-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ 'PORTALS.ALL_CATEGORIES' | translate }}
</h2>
@if (loading()) {
<span class="all-items-progress" role="status">
<mat-spinner diameter="14" />
@if (progress(); as loadProgress) {
<span class="all-items-progress__count">
{{ loadProgress.loaded }}/{{
loadProgress.total
}}
</span>
}
</span>
} @else if (filteredChannels().length > 0) {
<span class="category-subtitle"
>{{ filteredChannels().length }}
{{
(filteredChannels().length === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
>
}
</div>
@if (!loading() && filteredChannels().length > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="filteredChannels().length"
[pageSize]="pageSize()"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
aria-label="Select page"
/>
}
</div>
<app-grid-list
class="all-items-grid app-scrollbar"
[isLoading]="loading()"
[items]="pagedGridItems()"
[limit]="pageSize()"
[showPaginator]="false"
[searchTerm]="searchTerm()"
[variant]="'logo'"
[type]="'live'"
(itemClicked)="onItemClicked($event)"
/>
`,
styleUrl: './stalker-itv-all-items.component.scss',
})
export class StalkerItvAllItemsComponent {
readonly channels = input<StalkerItvChannel[]>([]);
readonly loading = input(false);
readonly progress = input<StalkerItvLoadProgress | null>(null);
readonly searchTerm = input('');
readonly channelActivated = output<StalkerItvChannel>();
readonly pageSizeOptions = [10, 25, 50, 100];
readonly pageSize = signal(25);
/** Resets to the first page whenever the source list or search changes. */
readonly pageIndex = linkedSignal({
source: () => ({
term: this.searchTerm(),
channelCount: this.channels().length,
}),
computation: () => 0,
});
readonly filteredChannels = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const channels = this.channels();
if (!term) {
return channels;
}
return channels.filter((channel) =>
`${channel.o_name ?? ''} ${channel.name ?? ''}`
.toLowerCase()
.includes(term)
);
});
/** The current page, mapped so GridListComponent can resolve the logo. */
readonly pagedGridItems = computed(() => {
const start = this.pageIndex() * this.pageSize();
return this.filteredChannels()
.slice(start, start + this.pageSize())
.map((channel) => {
// GridListItem forbids null is_series; Stalker payloads may
// carry it — drop the nullish form (same as toPlayableChannel).
const { is_series, ...rest } = channel;
return {
...rest,
...(is_series == null ? {} : { is_series }),
stream_icon: channel.logo,
};
});
});
onPageChange(event: PageEvent): void {
this.pageSize.set(event.pageSize);
this.pageIndex.set(event.pageIndex);
}
onItemClicked(item: unknown): void {
this.channelActivated.emit(item as StalkerItvChannel);
}
}
@@ -15,15 +15,30 @@
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
@if (visibleChannels().length > 0) {
@if (totalChannelCount() > 0) {
<span class="category-subtitle"
>{{ visibleChannels().length }}
>{{ totalChannelCount() }}
{{
visibleChannels().length === 1 ? 'item' : 'items'
(totalChannelCount() === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
>
}
</div>
@if (isFullListMode()) {
<button
mat-icon-button
type="button"
[disabled]="isFullListLoading()"
[attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
(click)="refreshChannels()"
>
<mat-icon>refresh</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
@@ -36,6 +51,20 @@
</button>
</div>
@if (isFullListLoading()) {
<div class="full-list-progress" role="status">
<mat-spinner diameter="14" />
<span class="full-list-progress__label">
{{ 'PORTALS.LOADING_ALL_CHANNELS' | translate }}
</span>
@if (fullListProgress(); as progress) {
<span class="full-list-progress__count">
{{ progress.loaded }}/{{ progress.total }}
</span>
}
</div>
}
<div #scrollContainer class="channels-list">
@if (isInitialChannelsLoading()) {
<app-channel-list-skeleton
@@ -50,6 +79,13 @@
[message]="'CHANNELS.NO_SEARCH_RESULTS' | translate"
/>
</div>
} @else if (isCategoryEmpty()) {
<div class="empty-search-state">
<app-portal-empty-state
icon="tv_off"
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
/>
</div>
} @else {
@for (item of visibleChannels(); track item.id ?? $index) {
<app-channel-list-item
@@ -72,6 +108,7 @@
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="supportsEpgMapping"
[isFavorite]="
favorites.get(normalizeStalkerEntityId(item.id)) ??
false
@@ -79,6 +116,7 @@
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
/>
}
}
@@ -106,11 +144,23 @@
<mat-icon>chevron_right</mat-icon>
</button>
}
@if (!stalkerStore.selectedCategoryId()) {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
@if (!stalkerStore.selectedItem() && !stalkerStore.selectedCategoryId()) {
@if (showItvAllItems()) {
<!-- Xtream parity: all channels of the portal, paginated, before
any category is picked. Clicking a card starts playback. -->
<app-stalker-itv-all-items
[channels]="itvFullChannelList()"
[loading]="isFullListLoading()"
[progress]="fullListProgress()"
[searchTerm]="searchTerm()"
(channelActivated)="playChannel($event)"
/>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
}
} @else if (stalkerStore.selectedItem()) {
@if (streamUrl() && (isEmbeddedPlayer || isRadioMode())) {
<div class="video-player">
@@ -206,3 +256,19 @@
}
</div>
</ng-template>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
</mat-menu>
@@ -36,6 +36,36 @@
);
}
.full-list-progress {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 0.72rem;
line-height: 1.2;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface-container);
border-bottom: 1px solid var(--app-separator);
mat-spinner {
flex: 0 0 auto;
}
&__label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
}
.channels-list {
flex: 1;
overflow-y: auto;
@@ -63,3 +93,11 @@
padding-inline: 6px;
}
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@@ -1,4 +1,11 @@
import { Component, Directive, input, output, signal } from '@angular/core';
import {
Component,
Directive,
EventEmitter,
input,
output,
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -25,6 +32,8 @@ import {
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { MatDialog } from '@angular/material/dialog';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
@@ -41,10 +50,12 @@ class StubChannelListItemComponent {
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly showDetailsContextMenu = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly activated = output<void>();
readonly favoriteToggled = output<void>();
readonly contextMenuRequested = output<MouseEvent>();
}
@Component({
@@ -134,7 +145,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
});
const selectedCategoryId = signal<string | null>('1001');
const searchPhrase = signal('');
const itvChannels = signal([
const defaultItvChannels = () => [
{
id: '10001',
cmd: 'ffrt4://itv/10001',
@@ -149,7 +160,8 @@ describe('StalkerLiveStreamLayoutComponent', () => {
o_name: 'Beta TV',
logo: 'beta.png',
},
]);
];
const itvChannels = signal(defaultItvChannels());
const radioChannels = signal([
{
id: 'radio-1',
@@ -182,6 +194,17 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const isLoadingBulkItvEpg = signal(false);
const hasMoreChannels = signal(false);
const page = signal(0);
const itvFullListActive = signal(false);
const itvFullListLoading = signal(false);
const itvFullListProgress = signal<{
loaded: number;
total: number;
} | null>(null);
const itvFullChannelList = signal<
ReturnType<typeof defaultItvChannels>
>([]);
const itvSelectedCategoryFromCache = signal(false);
const isPaginatedContentLoading = signal(false);
const stalkerStore = {
getSelectedCategoryName: signal('News'),
@@ -189,6 +212,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
radioChannels,
searchPhrase,
hasMoreChannels,
itvFullListActive,
itvFullListLoading,
itvFullListProgress,
itvFullChannelList,
itvSelectedCategoryFromCache,
isPaginatedContentLoading,
preloadItvChannels: jest.fn(),
refreshItvChannels: jest.fn().mockResolvedValue(undefined),
selectedItvId,
currentPlaylist: playlist,
selectedItvEpgPrograms,
@@ -215,6 +246,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
resolveRadioPlayback: jest.fn(),
fetchChannelEpg: jest.fn(),
ensureBulkItvEpg: jest.fn(),
applyMappedItvEpg: jest.fn().mockResolvedValue(undefined),
clearBulkItvEpgCache: jest.fn(() => {
bulkItvEpgByChannel.set({});
bulkItvEpgLoaded.set(false);
@@ -255,6 +287,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
resolveItvPlayback = stalkerStore.resolveItvPlayback;
resolveRadioPlayback = stalkerStore.resolveRadioPlayback;
itvChannels.set(defaultItvChannels());
selectedCategoryId.set('1001');
searchPhrase.set('');
stalkerStore.selectedContentType.set('itv');
@@ -268,6 +301,13 @@ describe('StalkerLiveStreamLayoutComponent', () => {
isLoadingBulkItvEpg.set(false);
hasMoreChannels.set(false);
page.set(0);
itvFullListActive.set(false);
itvFullListLoading.set(false);
itvFullListProgress.set(null);
itvFullChannelList.set([]);
itvSelectedCategoryFromCache.set(false);
isPaginatedContentLoading.set(false);
stalkerStore.preloadItvChannels.mockClear();
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
settingsStore.openStreamOnDoubleClick.set(false);
@@ -326,6 +366,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
get isElectron() {
return Boolean(window.electron);
},
get supportsEpgMapping() {
return false;
},
},
},
{ provide: PlaylistsService, useValue: playlistService },
@@ -335,6 +378,13 @@ describe('StalkerLiveStreamLayoutComponent', () => {
provide: TranslateService,
useValue: {
instant: jest.fn((value: string) => value),
// The real TranslatePipe (used by child components
// that aren't stubbed) needs these members too.
get: jest.fn((value: string) => of(value)),
stream: jest.fn((value: string) => of(value)),
onTranslationChange: new EventEmitter(),
onLangChange: new EventEmitter(),
onDefaultLangChange: new EventEmitter(),
},
},
{
@@ -343,6 +393,22 @@ describe('StalkerLiveStreamLayoutComponent', () => {
open: jest.fn(),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
{
provide: EpgRuntimeBridgeService,
useValue: {
supportsEpgMapping: false,
getEpgMapping: jest.fn().mockResolvedValue(null),
getEpgMappingsBatch: jest
.fn()
.mockResolvedValue(null),
},
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
@@ -621,6 +687,282 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(stalkerStore.setItvChannels).not.toHaveBeenCalled();
});
it('windows the rendered list in full-list mode while search covers everything', () => {
const initialChannels = itvChannels();
try {
const full = Array.from({ length: 250 }, (_, index) => ({
id: `ch-${index}`,
cmd: `ffrt4://itv/${index}`,
name: index === 249 ? 'Needle TV' : `Channel ${index}`,
o_name: index === 249 ? 'Needle TV' : `Channel ${index}`,
logo: '',
}));
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(true);
itvChannels.set(full);
itvFullChannelList.set(full);
fixture.detectChanges();
// Only the first render window hits the DOM…
expect(component.visibleChannels()).toHaveLength(100);
// …but the header count reflects the whole list.
expect(component.totalChannelCount()).toBe(250);
expect(component.hasMoreItems()).toBe(true);
stalkerStore.setPage.mockClear();
component.loadMore();
// Scrolling extends the window from memory without portal paging.
expect(component.visibleChannels()).toHaveLength(200);
expect(stalkerStore.setPage).not.toHaveBeenCalled();
// Regression: search matches channels far beyond the first page.
searchPhrase.set('needle');
fixture.detectChanges();
expect(
component.visibleChannels().map((channel) => channel.name)
).toEqual(['Needle TV']);
} finally {
itvChannels.set(initialChannels);
}
});
it('searches the whole portal (all categories) in full-list mode, not just the current category', () => {
// The current category (itvChannels) has only 'Alpha TV'/'Beta TV', but
// the portal's full list contains a News channel in another genre.
itvFullListActive.set(true);
itvFullChannelList.set([
...defaultItvChannels(),
{
id: '55',
cmd: 'ffrt4://itv/55',
name: 'CNN International',
o_name: 'CNN International',
logo: '',
},
]);
searchPhrase.set('cnn');
fixture.detectChanges();
expect(
component.filteredChannels().map((channel) => channel.name)
).toEqual(['CNN International']);
});
it('includes paged censored-category channels in full-list search results', () => {
// The adult category's channels come from the legacy paged flow and
// are intentionally absent from the full-list cache — searching for a
// currently visible channel must still find it (merged source).
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(false);
itvChannels.set([
{
id: 'adult-1',
cmd: 'ffrt4://itv/adult-1',
name: 'Erox HD',
o_name: 'Erox HD',
logo: '',
},
]);
itvFullChannelList.set(defaultItvChannels());
searchPhrase.set('erox');
fixture.detectChanges();
expect(
component.filteredChannels().map((channel) => channel.name)
).toEqual(['Erox HD']);
// And the cached portal-wide channels remain searchable too.
searchPhrase.set('alpha');
fixture.detectChanges();
expect(
component.filteredChannels().map((channel) => channel.name)
).toEqual(['Alpha TV']);
});
it('grows the render window to include a channel selected beyond it (remote/numeric nav)', async () => {
const full = Array.from({ length: 250 }, (_, index) => ({
id: `ch-${index}`,
cmd: `ffrt4://itv/${index}`,
name: `Channel ${index}`,
o_name: `Channel ${index}`,
logo: '',
}));
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(true);
itvChannels.set(full);
itvFullChannelList.set(full);
fixture.detectChanges();
expect(component.visibleChannels()).toHaveLength(100);
// Selecting channel index 150 (outside the 100-item window) must grow
// the window so it is rendered and can be highlighted/scrolled to.
await component.playChannel(full[150], false);
fixture.detectChanges();
expect(component.visibleChannels().length).toBeGreaterThan(150);
expect(
component
.visibleChannels()
.some((channel) => channel.id === 'ch-150')
).toBe(true);
});
it('does not clear cached channels when switching category in full-list mode', async () => {
// Regression: the category-change reset effect used to setItvChannels([])
// unconditionally, clobbering the list the content loader had just
// served synchronously from the full-list cache — leaving the sidebar
// stuck on an infinite skeleton for every category after the first.
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(true);
fixture.detectChanges();
await fixture.whenStable();
stalkerStore.setItvChannels.mockClear();
selectedCategoryId.set('42');
fixture.detectChanges();
await fixture.whenStable();
expect(stalkerStore.setItvChannels).not.toHaveBeenCalled();
});
it('still clears channels on category change when the full list is not active', async () => {
itvFullListActive.set(false);
fixture.detectChanges();
await fixture.whenStable();
stalkerStore.setItvChannels.mockClear();
selectedCategoryId.set('99');
fixture.detectChanges();
await fixture.whenStable();
expect(stalkerStore.setItvChannels).toHaveBeenCalledWith([]);
});
it('shows an empty state (not a skeleton) for a loaded but empty category', () => {
// Full list is loaded, nothing is loading, and the selected category
// filters down to zero channels — this must read as "empty", not "stuck".
itvFullListActive.set(true);
itvFullListLoading.set(false);
isPaginatedContentLoading.set(false);
itvChannels.set([]);
searchPhrase.set('');
fixture.detectChanges();
expect(component.isInitialChannelsLoading()).toBe(false);
expect(component.isCategoryEmpty()).toBe(true);
expect(
fixture.nativeElement.querySelector('app-channel-list-skeleton')
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'.empty-search-state app-portal-empty-state'
)
).toBeTruthy();
});
it('shows the skeleton only while a full-list load is genuinely in flight', () => {
itvChannels.set([]);
searchPhrase.set('');
itvFullListActive.set(false);
isPaginatedContentLoading.set(false);
itvFullListLoading.set(true);
fixture.detectChanges();
expect(component.isInitialChannelsLoading()).toBe(true);
itvFullListLoading.set(false);
fixture.detectChanges();
expect(component.isInitialChannelsLoading()).toBe(false);
});
it('shows the skeleton while the legacy paged fetch is loading', () => {
itvChannels.set([]);
searchPhrase.set('');
itvFullListActive.set(false);
itvFullListLoading.set(false);
isPaginatedContentLoading.set(true);
fixture.detectChanges();
expect(component.isInitialChannelsLoading()).toBe(true);
});
it('keeps portal pagination for a censored category absent from the cache', async () => {
// Full list IS active, but the selected (adult) genre has no cached
// channels — infinite scroll must request the next portal page instead
// of only growing the client-side render window.
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(false);
hasMoreChannels.set(true);
fixture.detectChanges();
await fixture.whenStable();
page.set(0);
stalkerStore.setPage.mockClear();
component.loadMore();
expect(stalkerStore.setPage).toHaveBeenCalledWith(1);
});
it('preloads the full channel list as soon as the Live TV section is entered', () => {
// No category selected — the preload must not wait for a category click.
selectedCategoryId.set(null);
fixture.detectChanges();
expect(stalkerStore.preloadItvChannels).toHaveBeenCalled();
});
it('shows the all-channels grid instead of the placeholder when the full list is available', () => {
selectedCategoryId.set(null);
selectedItem.set(null);
itvFullListActive.set(true);
itvFullChannelList.set(defaultItvChannels());
fixture.detectChanges();
const grid = fixture.nativeElement.querySelector(
'app-stalker-itv-all-items'
);
expect(grid).toBeTruthy();
expect(grid.querySelectorAll('mat-card')).toHaveLength(2);
expect(
fixture.nativeElement.querySelector('app-portal-empty-state')
).toBeNull();
});
it('keeps the select-a-category placeholder on portals without a usable full list', () => {
selectedCategoryId.set(null);
selectedItem.set(null);
itvFullListActive.set(false);
itvFullListLoading.set(false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('app-stalker-itv-all-items')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-portal-empty-state')
).toBeTruthy();
});
it('shows the refresh button in full-list mode and delegates to the store', () => {
itvFullListActive.set(true);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll(
'.category-content-header button'
)
) as HTMLButtonElement[];
const refreshButton = buttons.find((button) =>
button.textContent?.includes('refresh')
);
expect(refreshButton).toBeTruthy();
refreshButton?.click();
expect(stalkerStore.refreshItvChannels).toHaveBeenCalled();
});
it('persists the channels sidebar width under a dedicated storage key', () => {
// The shell context panel (category sidebar) is visible at the same
// time as this sidebar and persists its width under the shared
@@ -635,12 +977,38 @@ describe('StalkerLiveStreamLayoutComponent', () => {
);
});
it('keeps row previews empty before bulk epg is loaded', async () => {
async function settleEagerEpg(): Promise<void> {
for (let i = 0; i < 5; i += 1) {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve));
}
fixture.detectChanges();
}
it('loads bulk EPG and shows row previews on category entry, before any playback', async () => {
await settleEagerEpg();
// The list EPG previews appear as soon as the category's channels are
// shown — the user no longer has to play a channel first.
expect(ensureBulkItvEpg).toHaveBeenCalledWith(168);
expect(component.epgPreviewPrograms.get('10001')?.title).toBe(
'Current Show'
);
expect(component.epgPreviewPrograms.get('10002')?.title).toBe(
'Next Channel Show'
);
// The per-channel fallback is reserved for the playing channel.
expect(fetchChannelEpg).not.toHaveBeenCalled();
});
it('does not load bulk EPG for radio (no EPG data)', async () => {
stalkerStore.selectedContentType.set('radio');
selectedCategoryId.set('radio-all');
selectedItem.set(null);
fixture.detectChanges();
await fixture.whenStable();
expect(component.epgPreviewPrograms.size).toBe(0);
expect(fetchChannelEpg).not.toHaveBeenCalled();
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
});
@@ -660,16 +1028,18 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(fetchChannelEpg).not.toHaveBeenCalled();
});
it('ensures bulk EPG only once across channel switches for the same playlist', async () => {
fixture.detectChanges();
it('does not re-fetch bulk EPG when switching channels once it is loaded', async () => {
await settleEagerEpg();
// Bulk EPG has loaded (eagerly, on entry).
ensureBulkItvEpg.mockClear();
await component.playChannel(itvChannels()[0]);
await fixture.whenStable();
await component.playChannel(itvChannels()[1]);
await fixture.whenStable();
expect(ensureBulkItvEpg).toHaveBeenCalledTimes(1);
expect(ensureBulkItvEpg).toHaveBeenCalledWith(168);
// Playing/switching channels reuses the cached bulk EPG.
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
});
it('renders inline audio playback and no EPG for radio stations', async () => {
@@ -8,6 +8,7 @@ import {
effect,
ElementRef,
inject,
linkedSignal,
OnDestroy,
signal,
untracked,
@@ -15,7 +16,9 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -23,6 +26,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
ChannelListSkeletonComponent,
EpgMappingDialogComponent,
ResizableDirective,
} from '@iptvnator/ui/components';
import {
@@ -31,6 +35,7 @@ import {
SettingsStore,
} from '@iptvnator/services';
import {
buildStalkerEpgMappingKey,
Channel,
EpgItem,
EpgProgram,
@@ -50,6 +55,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
@@ -68,12 +74,16 @@ import {
StalkerStore,
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
type StalkerPlayableChannel = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
};
/** Channels rendered per "page" when the full list is served from the cache. */
const FULL_LIST_RENDER_CHUNK = 100;
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
@@ -86,11 +96,13 @@ type StalkerPlayableChannel = StalkerPortalItem & {
EpgTimelineComponent,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
PortalEmptyStateComponent,
ResizableDirective,
StalkerItvAllItemsComponent,
TranslatePipe,
WebPlayerViewComponent,
],
@@ -99,6 +111,8 @@ type StalkerPlayableChannel = StalkerPortalItem & {
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playlistService = inject(PlaylistsService);
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
@@ -122,33 +136,128 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly searchTerm = computed(() =>
this.stalkerStore.searchPhrase().trim().toLowerCase()
);
readonly visibleChannels = computed(() => {
const channels = this.channels();
/** Full-list mode: the complete channel list is cached, so search covers everything. */
readonly isFullListMode = computed(
() => !this.isRadioMode() && this.stalkerStore.itvFullListActive()
);
/**
* True when the CURRENT category is actually served from the cache.
* Censored (adult) genres are excluded from `get_all_channels` on most
* portals, so they stay on the legacy paged flow (portal pagination,
* infinite scroll) even while the full-list cache is active.
*/
readonly isCategoryFromCache = computed(
() =>
!this.isRadioMode() &&
this.stalkerStore.itvSelectedCategoryFromCache()
);
/**
* Channels matching the search phrase. Without a term, the current
* category. With a term in full-list mode, the WHOLE portal's channel list
* (every category) so search behaves like "search all channels" — merged
* with the currently loaded channels, because a censored (adult) category
* is paged from the portal and its channels are intentionally absent from
* the full-list cache. Otherwise the loaded channels of the current
* category.
*/
readonly filteredChannels = computed(() => {
const term = this.searchTerm();
if (!term) {
return channels;
return this.channels();
}
return channels.filter((item) =>
let source = this.channels();
if (this.isFullListMode()) {
const merged = new Map<string, StalkerItvChannel>();
for (const channel of source) {
merged.set(normalizeStalkerEntityId(channel.id), channel);
}
for (const channel of this.stalkerStore.itvFullChannelList()) {
const id = normalizeStalkerEntityId(channel.id);
if (!merged.has(id)) {
merged.set(id, channel);
}
}
source = [...merged.values()];
}
return source.filter((item) =>
`${item.o_name ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(term)
);
});
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
readonly isFullListLoading = computed(
() => !this.isRadioMode() && this.stalkerStore.itvFullListLoading()
);
readonly fullListProgress = this.stalkerStore.itvFullListProgress;
readonly itvFullChannelList = this.stalkerStore.itvFullChannelList;
/**
* All-channels grid in the main area when no category is selected yet
* (Xtream "All Items" parity). Falls back to the "select a category"
* placeholder on portals without a usable full list.
*/
readonly showItvAllItems = computed(
() =>
!this.isRadioMode() &&
!this.stalkerStore.selectedCategoryId() &&
(this.stalkerStore.itvFullListActive() ||
this.stalkerStore.itvFullListLoading())
);
/** Windowed render limit keeps the DOM bounded for multi-thousand channel lists. */
private readonly renderLimit = linkedSignal({
source: () => ({
term: this.searchTerm(),
category: this.stalkerStore.selectedCategoryId(),
contentType: this.stalkerStore.selectedContentType(),
}),
computation: () => FULL_LIST_RENDER_CHUNK,
});
readonly visibleChannels = computed(() =>
this.isCategoryFromCache()
? this.filteredChannels().slice(0, this.renderLimit())
: this.filteredChannels()
);
readonly totalChannelCount = computed(() => this.filteredChannels().length);
readonly hasMoreItems = computed(() =>
this.isCategoryFromCache()
? this.visibleChannels().length < this.filteredChannels().length
: this.stalkerStore.hasMoreChannels()
);
readonly isLoadingMore = signal(false);
/**
* Skeleton shows only while a load is genuinely in flight. An empty result
* once loading has settled is an empty category, not a stuck spinner — the
* full-list cache can legitimately filter a genre down to zero channels.
*/
readonly isInitialChannelsLoading = computed(
() =>
!!this.stalkerStore.selectedCategoryId() &&
this.channels().length === 0 &&
!this.searchTerm()
!this.searchTerm() &&
(this.isFullListLoading() ||
this.stalkerStore.isPaginatedContentLoading())
);
/** Category is loaded but has no channels (and the user isn't searching). */
readonly isCategoryEmpty = computed(
() =>
!!this.stalkerStore.selectedCategoryId() &&
!this.searchTerm() &&
this.channels().length === 0 &&
!this.isInitialChannelsLoading()
);
readonly selectedChannelId = this.stalkerStore.selectedItvId;
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
/** Context menu (Map EPG) */
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly contextMenuChannel = signal<StalkerItvChannel | null>(null);
readonly contextMenuPosition = signal({ x: '0px', y: '0px' });
readonly isElectron = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsEpgMapping = this.runtime.supportsEpgMapping;
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
@@ -276,14 +385,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
});
}
// Reset channels/page on category change
// Start the full ITV channel list load as soon as the Live TV section
// is entered (not on the first category click), so the all-channels
// grid and the category count badges are available immediately.
effect(() => {
const contentType = this.stalkerStore.selectedContentType();
const playlist = this.stalkerStore.currentPlaylist();
if (contentType === 'itv' && playlist) {
untracked(() => this.stalkerStore.preloadItvChannels());
}
});
// Reset channels/page on category change. When the new category is
// served from the cache, the content loader re-serves the filtered
// list synchronously, so clearing here would just clobber it (the
// reset effect runs after the store resource) and leave the list stuck
// empty. Categories on the legacy paged flow — cold cache AND censored
// genres missing from the cache — still clear to avoid flashing the
// previous category's channels during the async fetch.
effect(() => {
const contentType = this.stalkerStore.selectedContentType();
this.stalkerStore.selectedCategoryId();
untracked(() => {
if (contentType === 'radio') {
this.stalkerStore.setRadioChannels([]);
} else {
} else if (
!this.stalkerStore.itvSelectedCategoryFromCache()
) {
this.stalkerStore.setItvChannels([]);
}
this.stalkerStore.setPage(0);
@@ -311,6 +439,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
this.syncBulkEpgPreviews(channels);
// Overlay manual EPG mappings for the rendered channels; the
// store dedupes per channel id, so this is cheap on rerenders.
if (this.supportsEpgMapping && channels.length > 0) {
const channelIds = channels.map((channel) => channel.id);
untracked(() =>
void this.stalkerStore.applyMappedItvEpg(channelIds)
);
}
});
effect(() => {
@@ -326,6 +463,28 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.clearBulkItvEpgCache();
});
// Load the bulk ITV EPG as soon as a category's channels are available
// — not only after the first channel is played — so the per-channel
// "now playing" previews in the list and the EPG panel populate
// immediately. Registered AFTER the playlist-change effect above so a
// portal switch clears the stale cache first and this then refills it;
// ensureBulkItvEpg de-duplicates and reuses the cache, so this is safe
// to fire on every channel-list change.
effect(() => {
const hasChannels = this.itvChannels().length > 0;
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (
this.isRadioMode() ||
!this.supportsEpg ||
!hasChannels ||
!playlistId
) {
return;
}
untracked(() => void this.stalkerStore.ensureBulkItvEpg(168));
});
// Setup scroll listener when container becomes available
effect(() => {
const container = this.scrollContainer();
@@ -342,7 +501,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const selectedItem = this.stalkerStore.selectedItem();
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.visibleChannels();
const channels = this.filteredChannels();
if (selectedType !== 'itv' || !selectedItem?.id) {
remoteControl.updateRemoteControlStatus({
@@ -412,6 +571,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const requestId = ++this.playbackRequestId;
const channelId = normalizeStalkerEntityId(item.id);
this.stalkerStore.setSelectedItem(item);
this.ensureChannelWithinRenderWindow(channelId);
try {
const isRadioMode = this.isRadioMode();
@@ -501,13 +661,53 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
/**
* In full-list mode the rendered list is windowed to `renderLimit`. When a
* channel beyond that window is selected (remote channel-up/down, numeric
* select), grow the window so the selection is actually in the DOM and can
* be highlighted/scrolled to instead of drifting off-window.
*/
private ensureChannelWithinRenderWindow(channelId: string): void {
if (!this.isCategoryFromCache()) {
return;
}
const index = this.filteredChannels().findIndex(
(item) => normalizeStalkerEntityId(item.id) === channelId
);
if (index < 0 || index < this.renderLimit()) {
return;
}
const needed =
Math.ceil((index + 1) / FULL_LIST_RENDER_CHUNK) *
FULL_LIST_RENDER_CHUNK;
this.renderLimit.set(Math.max(this.renderLimit(), needed));
}
loadMore() {
if (this.isCategoryFromCache()) {
// Extends the render window over the in-memory list — no request.
if (this.hasMoreItems()) {
this.renderLimit.update(
(limit) => limit + FULL_LIST_RENDER_CHUNK
);
}
return;
}
// Legacy portal pagination — also used for censored (adult) genres
// that are absent from the full-list cache.
if (this.isLoadingMore() || !this.hasMoreItems()) return;
this.isLoadingMore.set(true);
const nextPage = this.stalkerStore.page() + 1;
this.stalkerStore.setPage(nextPage);
}
refreshChannels(): void {
void this.stalkerStore.refreshItvChannels();
}
onLiveEpgPanelCollapsedChange(collapsed: boolean): void {
const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded';
this.liveEpgPanelState.set(state);
@@ -544,6 +744,94 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.selectedLiveEpgDate.set(selectedDate);
}
// ── Context menu (Map EPG) ─────────────────────────────────────
onChannelContextMenu(channel: StalkerItvChannel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
}
async openEpgMapping(): Promise<void> {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const channelId = normalizeStalkerEntityId(channel.id);
if (!playlistId || !channelId) {
return;
}
const channelKey = buildStalkerEpgMappingKey(
String(playlistId),
channelId
);
const mappingBefore = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.o_name || channel.name || channelId,
playlistId: String(playlistId),
})
.afterClosed()
.subscribe(() => {
void this.refreshEpgAfterMappingChange(
channel,
channelKey,
mappingBefore?.epgChannelId ?? null
);
});
}
/**
* Reload EPG state when the dialog actually changed the mapping —
* covers both save and removal; a plain cancel skips the reload.
*/
private async refreshEpgAfterMappingChange(
channel: StalkerItvChannel,
channelKey: string,
epgChannelIdBefore: string | null
): Promise<void> {
const mappingAfter = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
if ((mappingAfter?.epgChannelId ?? null) === epgChannelIdBefore) {
return;
}
this.stalkerStore.clearBulkItvEpgCache();
const selectedId = this.selectedChannelId();
const selected = selectedId
? this.channels().find(
(item) =>
normalizeStalkerEntityId(item.id) ===
normalizeStalkerEntityId(selectedId)
)
: null;
await this.loadEpgForChannel(selected ?? channel);
// Use the unfiltered list so an active search filter cannot drop
// the playing channel's mapping override.
await this.stalkerStore.applyMappedItvEpg(
this.channels().map((item) => item.id)
);
}
private async loadEpgForChannel(item: StalkerItvChannel) {
if (!this.supportsEpg) {
this.fallbackEpgPrograms.set([]);
@@ -815,7 +1103,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return;
}
const channels = this.visibleChannels();
const channels = this.filteredChannels();
const nextItem = getAdjacentChannelItem(
channels,
activeItem.id,
@@ -843,7 +1131,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
const channel = getChannelItemByNumber(
this.visibleChannels(),
this.filteredChannels(),
command.number
);
if (!channel) {
@@ -6,6 +6,7 @@ import {
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
getVodSeriesSeasonNumber,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
import type {
@@ -15,6 +16,7 @@ import type {
export interface StalkerQuickStartButton {
labelKey: string;
labelParams?: Record<string, number>;
episodeLabel: string | null;
icon: string;
disabled: boolean;
@@ -72,6 +74,7 @@ export function getStalkerSeriesQuickStartButton(
return {
labelKey: action.labelKey,
labelParams: action.labelParams,
episodeLabel: action.episodeLabel,
icon: action.icon,
disabled: action.disabled,
@@ -158,16 +161,8 @@ function getLazyVodSeriesEpisodeLabel(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): string {
const seasonIndex = seasons.findIndex((item) => item.id === season.id);
const parsedSeasonNumber = Number(season.season_number);
const seasonNumber =
Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0
? parsedSeasonNumber
: getFallbackSeasonNumber(seasonIndex);
return formatSeriesEpisodeCode(seasonNumber, 1);
}
function getFallbackSeasonNumber(seasonIndex: number): number {
return seasonIndex >= 0 ? seasonIndex + 1 : 1;
return formatSeriesEpisodeCode(
getVodSeriesSeasonNumber(season, seasons),
1
);
}
@@ -0,0 +1,158 @@
import { TestBed } from '@angular/core/testing';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
describe('StalkerSeriesTmdbSeasonsService', () => {
let service: StalkerSeriesTmdbSeasonsService;
let getSeason: jest.Mock;
const episodesOfSeason = (season: number): XtreamSerieEpisode[] => [
{
id: `${season}-1`,
episode_num: 1,
season,
title: 'Episode 1',
} as unknown as XtreamSerieEpisode,
];
beforeEach(() => {
getSeason = jest.fn().mockResolvedValue({
overview: 'Season overview',
episodes: [{ episode_number: 1, name: 'The Marshal' }],
});
TestBed.configureTestingModule({
providers: [
StalkerSeriesTmdbSeasonsService,
{
provide: TmdbEnrichmentService,
useValue: { getSeason },
},
],
});
service = TestBed.inject(StalkerSeriesTmdbSeasonsService);
});
it('fetches the title-marked season for a renumbered single-season slice', async () => {
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
expect(getSeason).toHaveBeenCalledWith(82856, 2);
// Overlay still keys by the provider's season key
const overlaid = service.overlay(
{ '1': episodesOfSeason(1) },
82856
);
expect(overlaid['1'][0].title).toBe('The Marshal');
});
it('keeps provider numbering for multi-season items despite a marker', async () => {
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 3,
});
expect(getSeason).toHaveBeenCalledWith(82856, 1);
});
it('keeps provider numbering without fetch context', async () => {
await service.fetchSeason(82856, '1', episodesOfSeason(1));
expect(getSeason).toHaveBeenCalledWith(82856, 1);
});
it('skips a repeat fetch for the same resolved season', async () => {
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
expect(getSeason).toHaveBeenCalledTimes(1);
});
it('refetches when the same provider key resolves to another season', async () => {
getSeason.mockResolvedValueOnce({
overview: 'Season 2 overview',
episodes: [{ episode_number: 1, name: 'Season 2 Episode' }],
});
// Per-season slices of ONE show share (tmdbId, provider key "1")
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
expect(getSeason).toHaveBeenCalledWith(82856, 2);
getSeason.mockResolvedValueOnce({
overview: 'Season 3 overview',
episodes: [{ episode_number: 1, name: 'Season 3 Episode' }],
});
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (3 сезон)',
seasonCount: 1,
});
expect(getSeason).toHaveBeenCalledWith(82856, 3);
// The newer resolution overwrote the entry under the shared key
const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856);
expect(overlaid['1'][0].title).toBe('Season 3 Episode');
expect(service.descriptions(82856)['1']).toBe('Season 3 overview');
});
it('drops the stale entry when a replacement fetch fails', async () => {
getSeason.mockResolvedValueOnce({
overview: 'Season 2 overview',
episodes: [{ episode_number: 1, name: 'Season 2 Episode' }],
});
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
// Replacement resolution (another slice of the show) fails —
// the season-2 entry must not stay on screen for the new slice
getSeason.mockResolvedValueOnce(null);
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (3 сезон)',
seasonCount: 1,
});
const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856);
expect(overlaid['1'][0].title).toBe('Episode 1');
expect(service.descriptions(82856)).toEqual({});
// A later trigger retries and fills the correct season
getSeason.mockResolvedValueOnce({
overview: 'Season 3 overview',
episodes: [{ episode_number: 1, name: 'Season 3 Episode' }],
});
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (3 сезон)',
seasonCount: 1,
});
const healed = service.overlay({ '1': episodesOfSeason(1) }, 82856);
expect(healed['1'][0].title).toBe('Season 3 Episode');
});
it('does not cache a failed fetch, so a later trigger retries', async () => {
getSeason.mockResolvedValueOnce(null);
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
expect(getSeason).toHaveBeenCalledTimes(2);
const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856);
expect(overlaid['1'][0].title).toBe('The Marshal');
});
});
@@ -4,7 +4,16 @@ import {
mergeEpisodesWithTmdb,
type TmdbEpisode,
} from '@iptvnator/services';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import {
XtreamSerieEpisode,
resolveEnrichmentSeasonNumber,
} from '@iptvnator/shared/interfaces';
interface FetchedTmdbSeason {
/** Resolved TMDB season number this entry was fetched for */
seasonNumber: number;
episodes: TmdbEpisode[];
}
/**
* Component-scoped holder for lazily fetched TMDB season data (episode
@@ -12,18 +21,24 @@ import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
* the component's `providers`).
*
* Entries are keyed by `${tmdbId}|${seasonKey}` so data from a previously
* shown series can never leak into the current one.
* shown series can never leak into the current one, and each entry records
* the RESOLVED TMDB season it holds: per-season slices of one show share
* (tmdbId, provider key "1") but resolve to different seasons, and a fetch
* made with stale navigation context must be overwritten once the real
* context re-resolves — plain key idempotency would block both.
*/
@Injectable()
export class StalkerSeriesTmdbSeasonsService {
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
private readonly episodesByKey = signal<
ReadonlyMap<string, TmdbEpisode[]>
private readonly seasonsByKey = signal<
ReadonlyMap<string, FetchedTmdbSeason>
>(new Map());
private readonly overviewsByKey = signal<ReadonlyMap<string, string>>(
new Map()
);
/** mapKey → season number currently being fetched (in-flight dedup) */
private readonly pending = new Map<string, number>();
/**
* Overlays fetched TMDB episode data (real names, overviews, stills)
@@ -34,14 +49,14 @@ export class StalkerSeriesTmdbSeasonsService {
seasons: Record<string, XtreamSerieEpisode[]>,
tmdbId: number | null | undefined
): Record<string, XtreamSerieEpisode[]> {
const fetched = this.episodesByKey();
const fetched = this.seasonsByKey();
if (!tmdbId || fetched.size === 0) {
return seasons;
}
const merged: Record<string, XtreamSerieEpisode[]> = {};
for (const [seasonKey, episodes] of Object.entries(seasons)) {
const forSeason = fetched.get(`${tmdbId}|${seasonKey}`);
const forSeason = fetched.get(`${tmdbId}|${seasonKey}`)?.episodes;
merged[seasonKey] = forSeason?.length
? mergeEpisodesWithTmdb(episodes, forSeason)
: episodes;
@@ -72,47 +87,98 @@ export class StalkerSeriesTmdbSeasonsService {
/**
* Lazily pulls the TMDB season (episode list + overview) for an opened
* season; a no-op without a show-level TMDB match, with enrichment
* disabled, or when the season was already fetched.
* disabled, or when the entry already holds the resolved season.
* `context` carries the raw provider title and total season count so a
* per-season slice ("The Mandalorian (2 season)" with its single season
* renumbered to 1) fetches the season the title names instead of the
* provider's number. A later call resolving a DIFFERENT season for the
* same key (another slice of the show, or corrected navigation context)
* refetches and overwrites the entry.
*/
async fetchSeason(
tmdbId: number | null | undefined,
seasonKey: string,
episodes: XtreamSerieEpisode[] | undefined
episodes: XtreamSerieEpisode[] | undefined,
context?: { rawTitle?: string | null; seasonCount?: number }
): Promise<void> {
if (!tmdbId) {
return;
}
const providerSeasonNumber = Number(episodes?.[0]?.season ?? seasonKey);
if (!Number.isFinite(providerSeasonNumber)) {
return;
}
const seasonNumber = resolveEnrichmentSeasonNumber({
rawTitle: context?.rawTitle,
providerSeasonNumber,
providerSeasonCount: context?.seasonCount ?? 0,
});
const mapKey = `${tmdbId}|${seasonKey}`;
if (this.episodesByKey().has(mapKey)) {
const cached = this.seasonsByKey().get(mapKey);
if (
cached?.seasonNumber === seasonNumber ||
this.pending.get(mapKey) === seasonNumber
) {
return;
}
const seasonNumber = Number(episodes?.[0]?.season ?? seasonKey);
if (!Number.isFinite(seasonNumber)) {
return;
}
this.pending.set(mapKey, seasonNumber);
const season = await this.tmdbEnrichment.getSeason(
tmdbId,
seasonNumber
);
if (!season) {
return;
// A mismatched entry belongs to another slice/context of this
// show — drop it BEFORE fetching so a failed replacement fetch
// can never leave the wrong season's metadata on screen (the
// overlay then falls back to provider data until a retry).
if (cached) {
this.deleteEntry(mapKey);
}
try {
const season = await this.tmdbEnrichment.getSeason(
tmdbId,
seasonNumber
);
// A newer resolution for this key superseded us mid-flight —
// only the latest requested fetch may store its result.
if (this.pending.get(mapKey) !== seasonNumber) {
return;
}
if (!season) {
// Transient failure — stays uncached so a later trigger
// can retry.
return;
}
if (season.overview) {
const overviews = new Map(this.overviewsByKey());
overviews.set(mapKey, season.overview);
if (season.overview) {
overviews.set(mapKey, season.overview);
} else {
overviews.delete(mapKey);
}
this.overviewsByKey.set(overviews);
}
if (!season.episodes?.length) {
return;
const next = new Map(this.seasonsByKey());
next.set(mapKey, {
seasonNumber,
episodes: season.episodes ?? [],
});
this.seasonsByKey.set(next);
} finally {
if (this.pending.get(mapKey) === seasonNumber) {
this.pending.delete(mapKey);
}
}
}
const next = new Map(this.episodesByKey());
next.set(mapKey, season.episodes);
this.episodesByKey.set(next);
private deleteEntry(mapKey: string): void {
const next = new Map(this.seasonsByKey());
next.delete(mapKey);
this.seasonsByKey.set(next);
const overviews = new Map(this.overviewsByKey());
overviews.delete(mapKey);
this.overviewsByKey.set(overviews);
}
}
@@ -37,7 +37,7 @@
<div class="details__cast-strip">
@for (
member of serial.info.tmdb_cast;
track member.name
track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index
) {
<span
class="details__cast-chip"
@@ -78,12 +78,43 @@
}
</div>
}
@if (serial.info.director) {
@if (serial.info.director || serial.info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ serial.info.director }}</span>
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
@if (serial.info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (member of serial.info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="member.name"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ serial.info.director }}</span>
}
</div>
}
</ng-template>
@@ -104,7 +135,10 @@
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{ action.labelKey | translate }}
{{
action.labelKey
| translate: action.labelParams
}}
</span>
@if (action.episodeLabel) {
<span class="play-btn__meta">
@@ -122,6 +156,7 @@
detail-player
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="serial.info.name"
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
@@ -53,6 +53,7 @@ class StubSeasonContainerComponent {
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
@@ -79,6 +80,7 @@ describe('StalkerSeriesViewComponent', () => {
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasonsResource = signal<unknown[]>([]);
const vodSeriesSeasonsResource = signal<unknown[]>([]);
const isSerialSeasonsLoading = signal(false);
const fetchVodSeriesEpisodes = jest.fn();
const resolveVodPlayback = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
@@ -106,6 +108,7 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
vodSeriesSeasonsResource.set([]);
isSerialSeasonsLoading.set(false);
fetchVodSeriesEpisodes.mockReset();
resolveVodPlayback.mockReset();
resolveVodPlayback.mockImplementation(
@@ -156,7 +159,7 @@ describe('StalkerSeriesViewComponent', () => {
getVodSeriesSeasonsResource: () =>
vodSeriesSeasonsResource(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading: signal(false),
isSerialSeasonsLoading,
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay: jest.fn(),
@@ -241,7 +244,17 @@ describe('StalkerSeriesViewComponent', () => {
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${
params?.['episode'] ?? '{{episode}}'
}`;
}
return value ?? '';
}
),
],
},
@@ -290,6 +303,68 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('interpolates the episode number for a recently started VOD is_series episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: '1',
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([]);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
const episode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.episodePlaybackPositions.set(
new Map([
[
Number(episode.id),
{
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: 50001,
positionSeconds: 5,
durationSeconds: 100,
},
],
])
);
fixture.detectChanges();
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(
fixture.componentInstance.quickStartAction()?.labelParams
).toEqual({ episode: 1 });
expect(button?.textContent).toContain('Play episode 1');
expect(button?.textContent).not.toContain('{{episode}}');
});
it('loads the first VOD-series season and starts its first episode from quick start', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -350,6 +425,15 @@ describe('StalkerSeriesViewComponent', () => {
expect.any(Number),
undefined
);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
}),
true
);
});
it('loads an earlier unloaded VOD-series season before showing completed', async () => {
@@ -541,6 +625,14 @@ describe('StalkerSeriesViewComponent', () => {
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
})
);
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
@@ -727,4 +819,145 @@ describe('StalkerSeriesViewComponent', () => {
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1);
});
it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => {
serialSeasonsResource.set([]);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
// Season resource still loading — fetching now would pass a zero
// season count, suppress the title-marker override and cache the
// wrong season forever (fetchSeason is idempotent).
expect(tmdbGetSeason).not.toHaveBeenCalled();
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
fixture.detectChanges();
await fixture.whenStable();
// Single-season slice whose provider season is renumbered to 1:
// the title marker names the real TMDB season.
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
// No show-level TMDB match yet — nothing fetched for the first item
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Detail-to-detail navigation reuses the component; the new item's
// TMDB match can arrive while the season resource reloads and the
// map still shows the previous series' seasons.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30002',
cmd: '/media/file_30002.mpg',
info: {
name: 'Other Series (2 season)',
description: 'Other description',
movie_image: 'poster2.jpg',
tmdb_id: 888,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
// The new tmdb_id must NOT pair with the previous series' season
// context while the resource reloads.
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Once the new item's own seasons land, the fetch runs WITHOUT a
// new seasonSelected emission — the season container deduplicates
// emissions when both items share the same season-key set, so the
// retained key must stay usable.
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30002.mpg',
series: [1, 2],
},
]);
isSerialSeasonsLoading.set(false);
fixture.detectChanges();
await fixture.whenStable();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
it('enriches after equal-id navigation once the season resource settles', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
// Distinct items can reuse a provider id; the loading gate (not an
// id comparison) keeps the stale map from being used.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Different Series (3 season)',
description: 'Different description',
movie_image: 'poster3.jpg',
tmdb_id: 999,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.detectChanges();
await fixture.whenStable();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
it('reads the season marker from o_name when name is generic', async () => {
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
o_name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
});
@@ -27,6 +27,7 @@ import {
ResolvedPortalPlayback,
TmdbEnrichedCastMember,
XtreamSerieEpisode,
pickSeasonMarkedTitle,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import { SafePipe } from '@iptvnator/pipes';
@@ -162,7 +163,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
});
});
/** Season currently selected in the season container. */
/**
* Season currently selected in the season container. Deliberately NOT
* reset on detail-to-detail navigation: the season container keeps its
* own selection and deduplicates `seasonSelected` emissions, so when
* two items share the same season-key set (commonly just "1") it never
* re-emits — a parent-side reset would leave the new item permanently
* unenriched. Stale-context safety lives in the fetch effect's
* coherence gates instead (see the constructor).
*/
private readonly selectedSeasonKey = signal<string | null>(null);
/** Season descriptions for the season tabs (TMDB overview per season). */
@@ -204,14 +213,43 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// tmdb_id — so the fetch must re-run when the match arrives, not only
// on selection. fetchSeason is idempotent per (tmdbId, season).
effect(() => {
const tmdbId = this.displayItem()?.info?.tmdb_id;
const item = this.displayItem();
const tmdbId = item?.info?.tmdb_id;
const seasonKey = this.selectedSeasonKey();
if (tmdbId && seasonKey) {
// Coherence gates instead of timing assumptions. All inputs are
// read TRACKED so the effect re-runs as each one settles:
// - the season resource must not be mid-reload — during
// detail-to-detail navigation a reused component briefly
// pairs the NEW item's tmdb_id with the PREVIOUS item's map
// - the selected key must exist in the map with episodes — an
// empty map would pass seasonCount 0 (suppressing the
// title-marker override), and a key retained from the
// previous item is only usable when the new item has that
// season too (otherwise the container's auto-select re-emits)
// Re-running on overlay updates cannot loop (fetchSeason skips
// when its entry already holds the resolved season), and a
// fetch made with a stale snapshot is overwritten once the
// real context re-resolves to a different season.
const seasonsLoading = this.isVodSeries()
? this.isVodSeriesSeasonsLoading()
: this.isSerialSeasonsLoading();
const seasons = this.mappedSeasons();
const episodes = seasonKey ? seasons[seasonKey] : undefined;
if (tmdbId && seasonKey && !seasonsLoading && episodes?.length) {
untracked(() =>
void this.tmdbSeasons.fetchSeason(
tmdbId,
seasonKey,
this.mappedSeasons()[seasonKey]
episodes,
{
// The season marker can live in either title
// field (generic name + descriptive o_name)
rawTitle: pickSeasonMarkedTitle(
item?.info?.name,
item?.info?.o_name
),
seasonCount: Object.keys(seasons).length,
}
)
);
}
@@ -687,15 +725,37 @@ export class StalkerSeriesViewComponent implements OnDestroy {
episodeId,
startTime
);
const episodeState =
episodeId === undefined
? null
: resolveSeriesPlaybackEpisodeState({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId: episodeId,
fallbackEpisodeNumber: episodeNum,
});
const resolvedPlayback =
episodeState && playback.contentInfo?.contentType === 'episode'
? {
...playback,
contentInfo: {
...playback.contentInfo,
seasonNumber: episodeState.seasonNumber,
episodeNumber: episodeState.episodeNumber,
},
}
: playback;
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.inlinePlayback.set(resolvedPlayback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
void this.portalPlayer.openResolvedPlayback(
resolvedPlayback,
true
);
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
@@ -0,0 +1,141 @@
import { TestBed } from '@angular/core/testing';
import { SettingsStore } from '@iptvnator/services';
import { EpgQueueService } from './epg-queue.service';
import { XtreamApiService } from './xtream-api.service';
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
import type { EpgItem } from '@iptvnator/shared/interfaces';
/**
* Covers EpgQueueService.invalidate(), used when a manual EPG mapping for a
* stream changes. Split from epg-queue.service.spec.ts to keep both files
* under the max-lines limit.
*/
describe('EpgQueueService invalidation', () => {
let service: EpgQueueService;
let xtreamApi: { getShortEpg: jest.Mock };
const credentials = {
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
};
type ServicePrivates = {
fetchEpg: (
credentials: typeof credentials,
streamId: number
) => Promise<void>;
shouldFetch: (streamId: number) => boolean;
xmltvPreviewByStreamId: Map<number, EpgItem>;
epgChannelByStreamId: Map<number, string>;
inFlight: Set<number>;
};
beforeEach(() => {
xtreamApi = { getShortEpg: jest.fn().mockResolvedValue([]) };
TestBed.configureTestingModule({
providers: [
EpgQueueService,
{ provide: XtreamApiService, useValue: xtreamApi },
{
provide: XtreamXmltvFallbackService,
useValue: {
getProgramsForChannel: jest.fn().mockResolvedValue([]),
getCurrentProgramsBatch: jest.fn().mockResolvedValue({}),
},
},
{
provide: SettingsStore,
useValue: {
preferUploadedEpgOverXtream: jest.fn(() => false),
},
},
],
});
service = TestBed.inject(EpgQueueService);
});
function priv(): ServicePrivates {
return service as unknown as ServicePrivates;
}
function makeItem(channelId: string, title: string): EpgItem {
return {
id: `${channelId}|x`,
epg_id: '',
title,
lang: '',
start: '2026-05-07T08:00:00Z',
end: '2026-05-07T09:00:00Z',
stop: '2026-05-07T09:00:00Z',
description: '',
channel_id: channelId,
start_timestamp: '0',
stop_timestamp: '0',
};
}
it('drops every cached artifact so the stream refetches', async () => {
xtreamApi.getShortEpg.mockResolvedValue([makeItem('rtl.de', 'Now')]);
await priv().fetchEpg(credentials, 555);
priv().epgChannelByStreamId.set(555, 'rtl.de');
priv().xmltvPreviewByStreamId.set(555, makeItem('rtl.de', 'now'));
expect(service.getCached(555)).not.toBeNull();
expect(priv().shouldFetch(555)).toBe(false);
service.invalidate(555);
expect(service.getCached(555)).toBeNull();
expect(priv().shouldFetch(555)).toBe(true);
expect(priv().epgChannelByStreamId.has(555)).toBe(false);
expect(priv().xmltvPreviewByStreamId.has(555)).toBe(false);
});
it('discards an in-flight result when invalidated mid-request', async () => {
let resolveEpg!: (items: EpgItem[]) => void;
xtreamApi.getShortEpg.mockReturnValue(
new Promise<EpgItem[]>((resolve) => {
resolveEpg = resolve;
})
);
const emitted: number[] = [];
const sub = service.epgResult$.subscribe(({ streamId }) =>
emitted.push(streamId)
);
const fetchPromise = priv().fetchEpg(credentials, 707);
// The user changes the mapping while the request is still running.
service.invalidate(707);
// The provider now returns the pre-change (stale) result.
resolveEpg([makeItem('rtl.de', 'Stale')]);
await fetchPromise;
expect(service.getCached(707)).toBeNull();
expect(emitted).not.toContain(707);
sub.unsubscribe();
});
it('leaves a fresh in-flight marker intact when a stale request completes', async () => {
let resolveA!: (items: EpgItem[]) => void;
xtreamApi.getShortEpg.mockReturnValue(
new Promise<EpgItem[]>((resolve) => {
resolveA = resolve;
})
);
const aPromise = priv().fetchEpg(credentials, 909);
// Mapping changes mid-flight; a re-enqueue (request B) then starts and
// takes ownership of the in-flight marker.
service.invalidate(909);
priv().inFlight.add(909);
resolveA([makeItem('rtl.de', 'Stale')]);
await aPromise;
// Request A was stale, so its finally must not clear B's marker.
expect(priv().inFlight.has(909)).toBe(true);
});
});
@@ -1,6 +1,9 @@
import { inject, Injectable, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { EpgItem } from '@iptvnator/shared/interfaces';
import {
buildXtreamEpgMappingKey,
EpgItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
@@ -19,6 +22,8 @@ interface CacheEntry {
export interface EpgQueueEntry {
streamId: number;
epgChannelId?: string | null;
/** Owning playlist — required to resolve manual EPG mappings. */
playlistId?: string | null;
}
/**
@@ -50,6 +55,8 @@ export class EpgQueueService implements OnDestroy {
private readonly cache = new Map<number, CacheEntry>();
private queue: number[] = [];
private readonly inFlight = new Set<number>();
/** Bumped by invalidate() so a stale in-flight result is discarded. */
private readonly invalidationEpoch = new Map<number, number>();
private readonly epgChannelByStreamId = new Map<number, string>();
private readonly xmltvPreviewByStreamId = new Map<number, EpgItem>();
private visibleSet = new Set<number>();
@@ -75,6 +82,29 @@ export class EpgQueueService implements OnDestroy {
return entry.data;
}
/**
* Drop every cached artifact for a stream so the next enqueue refetches
* it. Used when a manual EPG mapping for the stream changes, since the
* cached preview/resolution was computed for the previous mapping.
*
* Also bumps an invalidation epoch and clears `inFlight`: a request that
* was already running when the mapping changed carries the pre-change
* resolution, so its result is discarded (epoch mismatch in `fetchEpg`)
* and clearing `inFlight` lets the immediate re-enqueue schedule a fresh
* fetch through the mapping-aware `enqueue()` path.
*/
invalidate(streamId: number): void {
this.cache.delete(streamId);
this.failureTimestamps.delete(streamId);
this.epgChannelByStreamId.delete(streamId);
this.xmltvPreviewByStreamId.delete(streamId);
this.inFlight.delete(streamId);
this.invalidationEpoch.set(
streamId,
(this.invalidationEpoch.get(streamId) ?? 0) + 1
);
}
private isFailureCoolingDown(streamId: number): boolean {
const timestamp = this.failureTimestamps.get(streamId);
if (timestamp == null) {
@@ -114,6 +144,17 @@ export class EpgQueueService implements OnDestroy {
typeof entry === 'number' ? { streamId: entry } : { ...entry }
);
// Resolve manual EPG mappings before building the per-EPG-id index.
// When the user has right-clicked a channel and created a mapping,
// the stored key is the playlist-scoped Xtream key, not the
// provider's epg_channel_id. By resolving upfront we get the
// correct EPG channel ID for the XMLTV batch call that follows.
// Guarded so environments without the bridge (PWA) skip the await
// entirely and the enqueue keeps its original microtask timing.
if (typeof window.electron?.getEpgMappingsBatch === 'function') {
await this.resolveManualMappings(normalized);
}
const streamsByEpgId = new Map<string, number[]>();
for (const entry of normalized) {
const id = entry.epgChannelId?.trim();
@@ -192,6 +233,62 @@ export class EpgQueueService implements OnDestroy {
return this.fallbackService.getCurrentProgramsBatch(epgChannelIds);
}
/**
* Resolve manual EPG mappings for the queued entries.
*
* The user may have opened the mapping dialog (right-click → "Map EPG")
* from any channel list; the stored key is the playlist-scoped Xtream
* key, which does not match the provider's epg_channel_id, so the batch
* IPC handler's resolveChannelIds() would miss it. We resolve here,
* upfront, so the XMLTV batch call later uses the *mapped* epgChannelId
* — the actual EPG channel that carries the XMLTV data. A mapping also
* supplies an epgChannelId to entries whose provider did not send one.
*/
private async resolveManualMappings(
entries: EpgQueueEntry[]
): Promise<void> {
const getEpgMappingsBatch =
typeof window.electron?.getEpgMappingsBatch === 'function'
? window.electron.getEpgMappingsBatch
: null;
if (!getEpgMappingsBatch) {
return;
}
const keyByStreamId = new Map<number, string>();
for (const entry of entries) {
if (!entry.playlistId) continue;
keyByStreamId.set(
entry.streamId,
buildXtreamEpgMappingKey(entry.playlistId, entry.streamId)
);
}
if (keyByStreamId.size === 0) {
return;
}
try {
// One IPC round-trip for the whole viewport — a per-entry
// lookup would put O(N) IPC calls on every scroll event.
const mappings = await getEpgMappingsBatch([
...keyByStreamId.values(),
]);
for (const entry of entries) {
const key = keyByStreamId.get(entry.streamId);
const mapped = key ? mappings[key]?.trim() : undefined;
if (mapped) {
this.logger.info(
`Mapped stream ${entry.streamId}: ${entry.epgChannelId ?? '(none)'} → ${mapped}`
);
entry.epgChannelId = mapped;
}
}
} catch {
// Mapping lookup failure is non-fatal; keep the original
// epgChannelId values and proceed.
}
}
private pruneEphemeralMaps(visibleIds: Set<number>): void {
for (const id of [...this.epgChannelByStreamId.keys()]) {
// getCached() honors TTL and lazily evicts expired entries;
@@ -233,6 +330,9 @@ export class EpgQueueService implements OnDestroy {
credentials: XtreamCredentials,
streamId: number
): Promise<void> {
const startEpoch = this.invalidationEpoch.get(streamId) ?? 0;
const isStale = (): boolean =>
(this.invalidationEpoch.get(streamId) ?? 0) !== startEpoch;
try {
const apiItems = await this.apiService.getShortEpg(
credentials,
@@ -241,6 +341,11 @@ export class EpgQueueService implements OnDestroy {
{ suppressErrorLog: true }
);
// A mapping change during the request invalidated this result.
if (isStale()) {
return;
}
if (apiItems.length > 0) {
this.recordSuccess(streamId, apiItems);
return;
@@ -249,13 +354,23 @@ export class EpgQueueService implements OnDestroy {
const xmltv = this.xmltvPreviewByStreamId.get(streamId);
this.recordSuccess(streamId, xmltv ? [xmltv] : []);
} catch (error) {
if (isStale()) {
return;
}
this.failureTimestamps.set(streamId, Date.now());
this.logger.error(
`Failed to load EPG for stream ${streamId}`,
error
);
} finally {
this.inFlight.delete(streamId);
// Only release the in-flight marker if this request still owns it.
// When invalidate() cleared it mid-flight, a later re-enqueue may
// already have started a new request for the same stream; an
// unconditional delete here would drop that request's marker and
// let a third concurrent fetch start.
if (!isStale()) {
this.inFlight.delete(streamId);
}
}
}
@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { DataService } from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
EpgItem,
XtreamCategory,
@@ -78,6 +79,7 @@ interface EpgResponse {
@Injectable({ providedIn: 'root' })
export class XtreamApiService {
private readonly dataService = inject(DataService);
private readonly logger = createLogger('XtreamApiService');
async cancelSession(sessionId: string): Promise<boolean> {
if (
@@ -91,7 +93,7 @@ export class XtreamApiService {
const result = await window.electron.xtreamCancelSession(sessionId);
return result.success;
} catch (error) {
console.error('Failed to cancel Xtream session:', error);
this.logger.error('Failed to cancel Xtream session:', error);
return false;
}
}
@@ -6,7 +6,7 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { EpgItem } from '@iptvnator/shared/interfaces';
import { buildXtreamEpgMappingKey, EpgItem } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
XtreamApiService,
@@ -40,7 +40,7 @@ const initialEpgState: EpgState = {
export function withEpg() {
const logger = createLogger('withEpg');
type ParentSelectionStoreLike = {
currentPlaylist?: () => XtreamCredentials | null;
currentPlaylist?: () => (XtreamCredentials & { id?: string }) | null;
selectedItem?: () => {
xtream_id?: number | null;
epg_channel_id?: string | null;
@@ -146,10 +146,33 @@ export function withEpg() {
patchState(store, { epgItems: [], isLoadingEpg: true });
// Resolve manual EPG mapping before the provider waterfall.
// The mapping dialog saves under the playlist-scoped
// Xtream key, so look it up and use the mapped EPG
// channel ID for the XMLTV / provider lookup.
let epgChannelId = selectedItem?.epg_channel_id ?? null;
const playlistId = storeAny.currentPlaylist?.()?.id;
if (runtime.supportsEpgMapping && playlistId) {
try {
const mapping =
await window.electron?.getEpgMapping?.(
buildXtreamEpgMappingKey(
playlistId,
xtreamId
)
);
if (mapping?.epgChannelId?.trim()) {
epgChannelId = mapping.epgChannelId.trim();
}
} catch {
// Non-fatal; keep original epgChannelId.
}
}
try {
const epgItems =
await fallbackService.resolveCurrentEpg({
epgChannelId: selectedItem.epg_channel_id,
epgChannelId,
preferUploaded: preferUploaded(),
fetchProvider: () =>
fetchFullProvider(credentials, xtreamId),
@@ -1,5 +1,6 @@
import type { TmdbEnrichmentService } from '@iptvnator/services';
import {
enrichSerialSeasonWithTmdb,
enrichSerialSelectionWithTmdb,
enrichVodSelectionWithTmdb,
} from './xtream-tmdb-enrichment';
@@ -25,6 +26,7 @@ function createEnrichment(overrides: Partial<TmdbEnrichmentService> = {}) {
isEnabled: jest.fn(() => true),
enrichMovie: jest.fn().mockResolvedValue(null),
enrichTv: jest.fn().mockResolvedValue(null),
getSeasonEpisodes: jest.fn().mockResolvedValue(null),
...overrides,
} as unknown as TmdbEnrichmentService;
}
@@ -173,3 +175,79 @@ describe('enrichSerialSelectionWithTmdb', () => {
expect(enrichment.enrichTv).not.toHaveBeenCalled();
});
});
describe('enrichSerialSeasonWithTmdb', () => {
function seasonSliceItem(
name: string,
episodes: Record<string, { episode_num: number; season: number }[]>
) {
return {
series_id: '7',
info: { ...serialItem.info, name, tmdb_id: 82856 },
episodes: Object.fromEntries(
Object.entries(episodes).map(([key, list]) => [
key,
list.map((episode) => ({
id: `${key}-${episode.episode_num}`,
title: `Episode ${episode.episode_num}`,
...episode,
})),
])
),
};
}
it('fetches the title-marked season for a renumbered single-season slice', async () => {
const store = createStore(
seasonSliceItem('The Mandalorian (2 season)', {
'1': [{ episode_num: 1, season: 1 }],
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest
.fn()
.mockResolvedValue([
{ episode_number: 1, name: 'The Marshal' },
]),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 2);
const updated = store.setSelectedItem.mock.calls[0][0] as {
episodes: Record<string, { title: string }[]>;
};
expect(updated.episodes['1'][0].title).toBe('The Marshal');
});
it('keeps provider numbering for multi-season items despite a marker', async () => {
const store = createStore(
seasonSliceItem('The Mandalorian (2 season)', {
'1': [{ episode_num: 1, season: 1 }],
'2': [{ episode_num: 1, season: 2 }],
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest.fn().mockResolvedValue([]),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1);
});
it('keeps provider numbering when the title has no marker', async () => {
const store = createStore(
seasonSliceItem('The Mandalorian', {
'1': [{ episode_num: 1, season: 1 }],
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest.fn().mockResolvedValue([]),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1);
});
});
@@ -9,6 +9,7 @@ import {
XtreamSerieDetails,
XtreamVodDetails,
getXtreamVodInfo,
resolveEnrichmentSeasonNumber,
} from '@iptvnator/shared/interfaces';
/**
@@ -162,11 +163,19 @@ export async function enrichSerialSeasonWithTmdb<
return;
}
const seasonNumber = Number(episodes[0]?.season ?? seasonKey);
if (!Number.isFinite(seasonNumber)) {
const providerSeasonNumber = Number(episodes[0]?.season ?? seasonKey);
if (!Number.isFinite(providerSeasonNumber)) {
return;
}
// Per-season provider slices ("The Mandalorian (2 season)") renumber
// their single season to 1 — the title marker names the real TMDB season
const seasonNumber = resolveEnrichmentSeasonNumber({
rawTitle: info.name,
providerSeasonNumber,
providerSeasonCount: Object.keys(selected?.episodes ?? {}).length,
});
const tmdbEpisodes = await enrichment.getSeasonEpisodes(
info.tmdb_id,
seasonNumber
@@ -43,12 +43,30 @@
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="supportsEpgMapping"
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)"
(activated)="playbackRequested.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
/>
</cdk-virtual-scroll-viewport>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
</mat-menu>
} @else {
<div class="empty-search-state">
<mat-icon class="empty-state-icon">search_off</mat-icon>
@@ -171,3 +171,11 @@
transform: translateY(0);
}
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@@ -13,15 +13,20 @@ import {
input,
OnDestroy,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import {
buildXtreamEpgMappingKey,
EpgItem,
EpgProgram,
XtreamCategory,
@@ -30,6 +35,7 @@ import {
import {
ChannelListItemComponent,
ChannelListSkeletonComponent,
EpgMappingDialogComponent,
} from '@iptvnator/ui/components';
import {
PortalChannelSortMode,
@@ -66,7 +72,9 @@ interface XtreamCategoryLike {
imports: [
ChannelListItemComponent,
ChannelListSkeletonComponent,
MatButtonModule,
MatIcon,
MatMenuModule,
ScrollingModule,
TranslatePipe,
],
@@ -83,7 +91,14 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
private readonly epgQueueService = inject(EpgQueueService);
private readonly route = inject(ActivatedRoute);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly contextMenuChannel = signal<XtreamChannelListItem | null>(null);
readonly contextMenuPosition = signal({ x: '0px', y: '0px' });
readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsEpgMapping = this.runtime.supportsEpgMapping;
readonly isSelectedTypeContentLoading =
this.xtreamStore.selectedTypeContentLoading;
readonly channels = computed(() => {
@@ -122,6 +137,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
epgPrograms = new Map<number, EpgProgram>();
currentProgramsProgress = new Map<number, number>();
/** Last viewport slice, reused to refresh previews after a mapping change. */
private lastVisibleChannels: XtreamChannelListItem[] = [];
readonly viewport = viewChild(CdkVirtualScrollViewport);
private subscriptions = new Subscription();
@@ -222,6 +240,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
range.start,
range.end
);
this.lastVisibleChannels = visibleChannels;
this.loadEpgForVisibleChannels(visibleChannels);
})
);
@@ -246,6 +265,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
const uncachedEntries: {
streamId: number;
epgChannelId?: string | null;
playlistId?: string | null;
}[] = [];
// Apply cached results immediately
@@ -266,6 +286,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
uncachedEntries.push({
streamId: channel.xtream_id,
epgChannelId: channel.epg_channel_id ?? null,
playlistId: playlist.id ?? null,
});
}
}
@@ -459,4 +480,99 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
? Math.floor(parsedDate / 1000)
: null;
}
// ── Context menu ────────────────────────────────────────────
onChannelContextMenu(channel: XtreamChannelListItem, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const xtreamId = channel.xtream_id ?? channel.id;
if (!playlistId || xtreamId == null) {
return;
}
const channelKey = buildXtreamEpgMappingKey(playlistId, xtreamId);
void this.openEpgMappingDialog(
channelKey,
channel,
xtreamId,
playlistId
);
}
private async openEpgMappingDialog(
channelKey: string,
channel: XtreamChannelListItem,
streamId: number,
playlistId: string
): Promise<void> {
const mappingBefore = await this.readEpgMapping(channelKey);
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.title ?? channel.name ?? String(streamId),
playlistId,
})
.afterClosed()
.subscribe(async () => {
const mappingAfter = await this.readEpgMapping(channelKey);
if (mappingAfter === mappingBefore) {
return;
}
// The mapping changed (saved or removed) — drop the cached
// preview/resolution and refetch so the row updates now
// instead of after the 5-minute TTL or the next scroll.
this.epgQueueService.invalidate(streamId);
this.epgPrograms.delete(streamId);
this.currentProgramsProgress.delete(streamId);
const visible = this.lastVisibleChannels.length
? this.lastVisibleChannels
: this.filteredChannels().slice(0, 50);
this.loadEpgForVisibleChannels(visible);
});
}
/** Read the current mapped EPG channel id, or null (PWA / no mapping). */
private async readEpgMapping(channelKey: string): Promise<string | null> {
if (!this.supportsEpgMapping) {
return null;
}
const bridge = (
window as unknown as {
electron?: {
getEpgMapping?: (
key: string
) => Promise<{ epgChannelId?: string } | null>;
};
}
).electron;
try {
const mapping = await bridge?.getEpgMapping?.(channelKey);
return mapping?.epgChannelId?.trim() || null;
} catch {
return null;
}
}
}
@@ -37,7 +37,7 @@
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_cast; track member.name) {
@for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -77,12 +77,43 @@
}
</div>
}
@if (info.director) {
@if (info.director || info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ info.director }}</span>
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
@if (info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="member.name"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ info.director }}</span>
}
</div>
}
</ng-template>
@@ -134,6 +165,7 @@
detail-player
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="item.info.name"
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
@@ -54,6 +54,7 @@ class StubSeasonContainerComponent {
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
@@ -37,7 +37,7 @@
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_cast; track member.name) {
@for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -79,12 +79,43 @@
}
</div>
}
@if (info.director) {
@if (info.director || info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ info.director }}</span>
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
@if (info.tmdb_directors?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="member.name"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ info.director }}</span>
}
</div>
}
@if (info.rating_kinopoisk) {
@@ -182,6 +213,14 @@
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button
class="download-btn download-btn--downloading"
@@ -219,6 +219,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
});
readonly isPausedDownload = computed(() => {
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isPaused(vodId, playlistId, 'vod');
});
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
);
@@ -431,6 +439,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.handleExternalFallbackRequest(request);
}
async resumePausedDownload(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
await this.downloadsService.resumeDownloadByContent(
this.selectedVodId(),
playlistId,
'vod'
);
}
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (!vodItem) {
return;
@@ -21,6 +21,8 @@ type TestDownloadsService = {
hasLoadedDownloads: Signal<boolean>;
loadDownloads: DownloadsService['loadDownloads'];
loadDownloadFolder: DownloadsService['loadDownloadFolder'];
pauseDownload: DownloadsService['pauseDownload'];
resumeDownload: DownloadsService['resumeDownload'];
selectFolder: DownloadsService['selectFolder'];
_isLoadingDownloads: WritableSignal<boolean>;
_hasLoadedDownloads: WritableSignal<boolean>;
@@ -29,6 +31,11 @@ type TestDownloadsService = {
type DownloadsElectronStub = {
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
downloadsPause?: jest.Mock<Promise<{ success: boolean }>, [number]>;
downloadsResume?: jest.Mock<
Promise<{ success: boolean }>,
[number, string]
>;
downloadsSelectFolder?: jest.Mock<Promise<string | null>, []>;
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
};
@@ -163,6 +170,45 @@ describe('DownloadsService', () => {
expect(electron.downloadsSelectFolder).toHaveBeenCalledTimes(1);
});
it('forwards pause requests to the main process', async () => {
const electron = {
downloadsGetList: jest.fn(async () => []),
downloadsPause: jest.fn(async (downloadId: number) => ({
success: downloadId === 42,
})),
};
testWindow.electron = electron;
const service = createService();
await expect(service.pauseDownload(42)).resolves.toEqual({
success: true,
});
expect(electron.downloadsPause).toHaveBeenCalledWith(42);
});
it('resolves the authorized folder before resuming a paused download', async () => {
const electron = {
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
downloadsGetList: jest.fn(async () => []),
downloadsResume: jest.fn(
async (downloadId: number, downloadFolder: string) => ({
success:
downloadId === 42 && downloadFolder === '/downloads',
})
),
};
testWindow.electron = electron;
const service = createService();
await expect(service.resumeDownload(42)).resolves.toEqual({
success: true,
});
expect(electron.downloadsResume).toHaveBeenCalledWith(
42,
'/downloads'
);
});
it('marks downloads as loaded after a failed request while preserving existing data', async () => {
const existing = createDownload(1);
const error = new Error('download query failed');
@@ -225,4 +271,45 @@ describe('DownloadsService', () => {
'playlist-new'
);
});
it('reports paused content and resumes it by content identity', async () => {
const pausedItem = {
...createDownload(7),
status: 'paused' as DownloadItem['status'],
};
const service = createService([
pausedItem,
createDownload(8),
]) as unknown as DownloadsService;
const resumeDownload = jest
.fn()
.mockResolvedValue({ success: true });
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
resumeDownload;
expect(service.isPaused(7, 'playlist-1', 'vod')).toBe(true);
expect(service.isPaused(8, 'playlist-1', 'vod')).toBe(false);
await expect(
service.resumeDownloadByContent(7, 'playlist-1', 'vod')
).resolves.toEqual({ success: true });
expect(resumeDownload).toHaveBeenCalledWith(7);
});
it('rejects resume-by-content when the download is not paused', async () => {
const service = createService([
createDownload(8),
]) as unknown as DownloadsService;
const resumeDownload = jest.fn();
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
resumeDownload;
await expect(
service.resumeDownloadByContent(8, 'playlist-1', 'vod')
).resolves.toEqual({
error: 'No paused download found',
success: false,
});
expect(resumeDownload).not.toHaveBeenCalled();
});
});
+81 -2
View File
@@ -4,6 +4,7 @@ import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'paused'
| 'completed'
| 'failed'
| 'canceled';
@@ -221,6 +222,53 @@ export class DownloadsService implements OnDestroy {
}
}
/**
* Pause a queued or active download
*/
async pauseDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsPause(downloadId);
} catch (error) {
console.error('[DownloadsService] Error pausing download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Resume a paused download
*/
async resumeDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
const folder = await this.loadDownloadFolder();
if (!folder) {
return { success: false, error: 'No download folder configured' };
}
try {
return await window.electron.downloadsResume(downloadId, folder);
} catch (error) {
console.error('[DownloadsService] Error resuming download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Retry a failed download
*/
@@ -401,7 +449,7 @@ export class DownloadsService implements OnDestroy {
}
/**
* Check if content is currently downloading or queued
* Check if content is currently downloading, queued, or paused
*/
isDownloading(
xtreamId: number,
@@ -409,7 +457,38 @@ export class DownloadsService implements OnDestroy {
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'downloading' || download?.status === 'queued';
return (
download?.status === 'downloading' ||
download?.status === 'queued' ||
download?.status === 'paused'
);
}
/**
* Check if content has a paused download
*/
isPaused(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'paused';
}
/**
* Resume the paused download that belongs to a content item
*/
async resumeDownloadByContent(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): Promise<{ success: boolean; error?: string }> {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
if (!download || download.status !== 'paused') {
return { success: false, error: 'No paused download found' };
}
return this.resumeDownload(download.id);
}
/**
+2 -3
View File
@@ -898,9 +898,8 @@ export class PlaylistsService {
path?: string
) {
try {
// Dynamic import keeps the ~130KB validator dep (transitively pulled
// by iptv-playlist-parser) out of the eager bundle. parse() only runs
// on user-triggered imports.
// Dynamic import keeps the parser out of the eager bundle;
// parse() only runs on user-triggered imports.
const parserModule = await import('iptv-playlist-parser');
const parse = resolvePlaylistParser(parserModule);
const parsedPlaylist = parse(playlist);
@@ -97,6 +97,8 @@ describe('RuntimeCapabilitiesService', () => {
dbRestoreXtreamUserData: jest.fn(),
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
downloadsPause: jest.fn(),
downloadsResume: jest.fn(),
downloadsRetry: jest.fn(),
downloadsRemove: jest.fn(),
downloadsGetList: jest.fn(),
@@ -366,6 +368,8 @@ describe('RuntimeCapabilitiesService', () => {
testWindow.electron = {
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
downloadsPause: jest.fn(),
downloadsResume: jest.fn(),
downloadsRetry: jest.fn(),
downloadsRemove: jest.fn(),
downloadsGetList: jest.fn(),
@@ -115,6 +115,15 @@ export class RuntimeCapabilitiesService {
return this.hasElectronMethod('searchEpgPrograms');
}
get supportsEpgMapping(): boolean {
return (
this.hasElectronMethod('getEpgMapping') &&
this.hasElectronMethod('setEpgMapping') &&
this.hasElectronMethod('deleteEpgMapping') &&
this.hasElectronMethod('searchEpgChannels')
);
}
get supportsSqlite(): boolean {
return [
'dbDeleteAllPlaylists',
@@ -174,6 +183,8 @@ export class RuntimeCapabilitiesService {
return [
'downloadsStart',
'downloadsCancel',
'downloadsPause',
'downloadsResume',
'downloadsRetry',
'downloadsRemove',
'downloadsGetList',
@@ -145,6 +145,24 @@ describe('SettingsStore dashboard rail settings', () => {
expect(store.getSettings().stripCountryPrefix).toBe(false);
});
it('defaults ambient player mode to false when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().playerAmbientMode).toBe(false);
});
it('restores a persisted true ambient player mode preference', async () => {
storedSettings = { playerAmbientMode: true };
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().playerAmbientMode).toBe(true);
});
it('restores a persisted true strip country prefix preference', async () => {
storedSettings = {
stripCountryPrefix: true,
@@ -27,6 +27,7 @@ import {
const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: false,
playerAmbientMode: false,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
@@ -188,6 +189,9 @@ export const SettingsStore = signalStore(
player: store.player(),
webPlayerSharedControls:
store.webPlayerSharedControls?.() === true,
playerAmbientMode:
store.playerAmbientMode?.() ??
DEFAULT_SETTINGS.playerAmbientMode,
streamFormat: store.streamFormat(),
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
language: store.language(),
@@ -78,10 +78,10 @@ describe('extractYear', () => {
describe('lookup keys', () => {
it('builds stable search and details keys', () => {
expect(buildSearchLookupKey('the matrix', 1999)).toBe(
'title:the matrix|year:1999'
'title:the matrix|year:1999|v2'
);
expect(buildSearchLookupKey('the matrix', null)).toBe(
'title:the matrix|year:'
'title:the matrix|year:|v2'
);
expect(buildDetailsLookupKey(603)).toBe('id:603|v2');
});
+4 -1
View File
@@ -70,7 +70,10 @@ export function buildSearchLookupKey(
normalizedTitle: string,
year: number | null
): string {
return `title:${normalizedTitle}|year:${year ?? ''}`;
// v2: normalizeTitleKeys learned to strip appended language/quality
// tags; the version suffix invalidates cached (incl. negative) match
// resolutions keyed on the old polluted titles
return `title:${normalizedTitle}|year:${year ?? ''}|v2`;
}
export function buildDetailsLookupKey(tmdbId: number): string {
@@ -0,0 +1,113 @@
import { StalkerVodInfo } from '@iptvnator/shared/interfaces';
import { mergeStalkerInfoWithTmdb } from './tmdb-merge';
import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types';
describe('mergeStalkerInfoWithTmdb', () => {
const providerStalkerInfo: StalkerVodInfo = {
movie_image: '',
description: '',
name: 'Ирония судьбы, или С лёгким паром!',
o_name: undefined,
actors: 'Провайдерский актёр',
director: '',
releasedate: '1976',
genre: '',
rating_imdb: '',
rating_kinopoisk: '8.1',
};
const tmdbMovieRu: TmdbMovieDetails = {
id: 20992,
title: 'Ирония судьбы, или С лёгким паром!',
overview: 'Описание из TMDB',
genres: [{ id: 35, name: 'комедия' }],
release_date: '1976-01-01',
vote_average: 7.9,
vote_count: 250,
poster_path: '/irony-poster.jpg',
backdrop_path: '/irony-backdrop.jpg',
credits: {
cast: [
{
name: 'Андрей Мягков',
order: 0,
profile_path: '/myagkov.jpg',
},
],
crew: [{ id: 77, name: 'Эльдар Рязанов', job: 'Director' }],
},
};
it('prefers TMDB editorial fields and attaches cast/backdrop', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
tmdbMovieRu,
'movie'
);
expect(merged.description).toBe('Описание из TMDB');
expect(merged.actors).toBe('Андрей Мягков');
expect(merged.director).toBe('Эльдар Рязанов');
expect(merged.tmdb_directors?.[0]).toEqual({
name: 'Эльдар Рязанов',
profileUrl: null,
tmdbPersonId: 77,
});
expect(merged.genre).toBe('комедия');
expect(merged.movie_image).toBe(
'https://image.tmdb.org/t/p/w500/irony-poster.jpg'
);
expect(merged.tmdb_backdrop).toBe(
'https://image.tmdb.org/t/p/w1280/irony-backdrop.jpg'
);
expect(merged.tmdb_cast).toEqual([
{
name: 'Андрей Мягков',
profileUrl: 'https://image.tmdb.org/t/p/w185/myagkov.jpg',
},
]);
// Provider keeps its own fields where TMDB should not win
expect(merged.name).toBe('Ирония судьбы, или С лёгким паром!');
expect(merged.rating_kinopoisk).toBe('8.1');
expect(merged.releasedate).toBe('1976');
});
it('only fills rating_imdb when the provider left it empty', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
tmdbMovieRu,
'movie'
);
expect(merged.rating_imdb).toBe('7.9');
const withProviderRating = mergeStalkerInfoWithTmdb(
{ ...providerStalkerInfo, rating_imdb: '8.4' },
tmdbMovieRu,
'movie'
);
expect(withProviderRating.rating_imdb).toBe('8.4');
});
it('uses series creators as director for tv items', () => {
const tv: TmdbTvDetails = {
id: 1,
overview: 'tv overview',
created_by: [{ name: 'Создатель Сериала' }],
};
const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv');
expect(merged.director).toBe('Создатель Сериала');
});
it('keeps provider values for missing TMDB fields', () => {
const merged = mergeStalkerInfoWithTmdb(
{ ...providerStalkerInfo, description: 'Провайдерский сюжет' },
{ id: 20992 },
'movie'
);
expect(merged.description).toBe('Провайдерский сюжет');
expect(merged.actors).toBe('Провайдерский актёр');
expect(merged.tmdb_cast).toBeUndefined();
expect(merged.tmdb_backdrop).toBeUndefined();
});
});
+38 -119
View File
@@ -1,13 +1,5 @@
import {
StalkerVodInfo,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import {
mergeSerieInfoWithTmdb,
mergeStalkerInfoWithTmdb,
mergeVodInfoWithTmdb,
} from './tmdb-merge';
import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces';
import { mergeSerieInfoWithTmdb, mergeVodInfoWithTmdb } from './tmdb-merge';
import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types';
function providerVodInfo(overrides: Partial<XtreamVodInfo> = {}): XtreamVodInfo {
@@ -68,8 +60,15 @@ const tmdbMovie: TmdbMovieDetails = {
{ name: 'Laurence Fishburne', order: 1, profile_path: null },
],
crew: [
{ name: 'Lana Wachowski', job: 'Director' },
{ name: 'Lilly Wachowski', job: 'Director' },
{
id: 9339,
name: 'Lana Wachowski',
job: 'Director',
profile_path: '/lana.jpg',
},
{ id: 9340, name: 'Lilly Wachowski', job: 'Director' },
// Duplicate crew row TMDB sometimes returns — must be deduped
{ id: 9339, name: 'Lana Wachowski', job: 'Director' },
{ name: 'Someone Else', job: 'Producer' },
],
},
@@ -106,7 +105,21 @@ describe('mergeVodInfoWithTmdb', () => {
expect(merged.description).toBe('TMDB overview');
expect(merged.cast).toBe('Keanu Reeves, Laurence Fishburne');
expect(merged.actors).toBe('Keanu Reeves, Laurence Fishburne');
expect(merged.director).toBe('Lana Wachowski, Lilly Wachowski');
expect(merged.director).toBe(
'Lana Wachowski, Lilly Wachowski, Lana Wachowski'
);
expect(merged.tmdb_directors).toEqual([
{
name: 'Lana Wachowski',
profileUrl: 'https://image.tmdb.org/t/p/w185/lana.jpg',
tmdbPersonId: 9339,
},
{
name: 'Lilly Wachowski',
profileUrl: null,
tmdbPersonId: 9340,
},
]);
expect(merged.genre).toBe('Action, Science Fiction');
expect(merged.rating).toBe(8.2);
expect(merged.tmdb_id).toBe(603);
@@ -242,7 +255,10 @@ describe('mergeSerieInfoWithTmdb', () => {
vote_count: 3200,
poster_path: '/dark-poster.jpg',
backdrop_path: '/dark-backdrop.jpg',
created_by: [{ name: 'Baran bo Odar' }, { name: 'Jantje Friese' }],
created_by: [
{ id: 91, name: 'Baran bo Odar', profile_path: '/odar.jpg' },
{ name: 'Jantje Friese' },
],
credits: {
cast: [{ name: 'Louis Hofmann', order: 0 }],
},
@@ -254,6 +270,14 @@ describe('mergeSerieInfoWithTmdb', () => {
expect(merged.plot).toBe('TMDB tv overview');
expect(merged.cast).toBe('Louis Hofmann');
expect(merged.director).toBe('Baran bo Odar, Jantje Friese');
expect(merged.tmdb_directors).toEqual([
{
name: 'Baran bo Odar',
profileUrl: 'https://image.tmdb.org/t/p/w185/odar.jpg',
tmdbPersonId: 91,
},
{ name: 'Jantje Friese', profileUrl: null },
]);
expect(merged.genre).toBe('Mystery');
expect(merged.rating).toBe('8.4');
expect(merged.rating_5based).toBe(4.2);
@@ -279,108 +303,3 @@ describe('mergeSerieInfoWithTmdb', () => {
expect(merged.cover).toBe('http://provider/cover.jpg');
});
});
describe('mergeStalkerInfoWithTmdb', () => {
const providerStalkerInfo: StalkerVodInfo = {
movie_image: '',
description: '',
name: 'Ирония судьбы, или С лёгким паром!',
o_name: undefined,
actors: 'Провайдерский актёр',
director: '',
releasedate: '1976',
genre: '',
rating_imdb: '',
rating_kinopoisk: '8.1',
};
const tmdbMovieRu: TmdbMovieDetails = {
id: 20992,
title: 'Ирония судьбы, или С лёгким паром!',
overview: 'Описание из TMDB',
genres: [{ id: 35, name: 'комедия' }],
release_date: '1976-01-01',
vote_average: 7.9,
vote_count: 250,
poster_path: '/irony-poster.jpg',
backdrop_path: '/irony-backdrop.jpg',
credits: {
cast: [
{
name: 'Андрей Мягков',
order: 0,
profile_path: '/myagkov.jpg',
},
],
crew: [{ name: 'Эльдар Рязанов', job: 'Director' }],
},
};
it('prefers TMDB editorial fields and attaches cast/backdrop', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
tmdbMovieRu,
'movie'
);
expect(merged.description).toBe('Описание из TMDB');
expect(merged.actors).toBe('Андрей Мягков');
expect(merged.director).toBe('Эльдар Рязанов');
expect(merged.genre).toBe('комедия');
expect(merged.movie_image).toBe(
'https://image.tmdb.org/t/p/w500/irony-poster.jpg'
);
expect(merged.tmdb_backdrop).toBe(
'https://image.tmdb.org/t/p/w1280/irony-backdrop.jpg'
);
expect(merged.tmdb_cast).toEqual([
{
name: 'Андрей Мягков',
profileUrl: 'https://image.tmdb.org/t/p/w185/myagkov.jpg',
},
]);
// Provider keeps its own fields where TMDB should not win
expect(merged.name).toBe('Ирония судьбы, или С лёгким паром!');
expect(merged.rating_kinopoisk).toBe('8.1');
expect(merged.releasedate).toBe('1976');
});
it('only fills rating_imdb when the provider left it empty', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
tmdbMovieRu,
'movie'
);
expect(merged.rating_imdb).toBe('7.9');
const withProviderRating = mergeStalkerInfoWithTmdb(
{ ...providerStalkerInfo, rating_imdb: '8.4' },
tmdbMovieRu,
'movie'
);
expect(withProviderRating.rating_imdb).toBe('8.4');
});
it('uses series creators as director for tv items', () => {
const tv: TmdbTvDetails = {
id: 1,
overview: 'tv overview',
created_by: [{ name: 'Создатель Сериала' }],
};
const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv');
expect(merged.director).toBe('Создатель Сериала');
});
it('keeps provider values for missing TMDB fields', () => {
const merged = mergeStalkerInfoWithTmdb(
{ ...providerStalkerInfo, description: 'Провайдерский сюжет' },
{ id: 20992 },
'movie'
);
expect(merged.description).toBe('Провайдерский сюжет');
expect(merged.actors).toBe('Провайдерский актёр');
expect(merged.tmdb_cast).toBeUndefined();
expect(merged.tmdb_backdrop).toBeUndefined();
});
});
+49
View File
@@ -64,6 +64,46 @@ function creatorNames(details: TmdbTvDetails): string {
.join(', ');
}
/**
* Directors (movies) as clickable person chips — same shape as the cast
* chips, so a director opens the same person page as an actor. Deduped by
* TMDB id to collapse the duplicate crew rows TMDB sometimes returns.
*/
function enrichedDirectors(
credits: TmdbCredits | undefined
): TmdbEnrichedCastMember[] {
const seen = new Set<number>();
const directors: TmdbEnrichedCastMember[] = [];
for (const member of credits?.crew ?? []) {
if (member.job !== 'Director' || !member.name) {
continue;
}
if (member.id !== undefined) {
if (seen.has(member.id)) {
continue;
}
seen.add(member.id);
}
directors.push({
name: member.name,
profileUrl: tmdbProfileUrl(member.profile_path),
...(member.id ? { tmdbPersonId: member.id } : {}),
});
}
return directors;
}
/** Series creators as clickable person chips (TV shows have no director) */
function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] {
return (details.created_by ?? [])
.filter((creator) => Boolean(creator.name))
.map((creator) => ({
name: creator.name,
profileUrl: tmdbProfileUrl(creator.profile_path),
...(creator.id ? { tmdbPersonId: creator.id } : {}),
}));
}
const MAX_RECOMMENDATIONS = 12;
/** Best YouTube trailer key: official trailer > any trailer > teaser */
@@ -130,6 +170,7 @@ export function mergeVodInfoWithTmdb(
details: TmdbMovieDetails
): XtreamVodInfo {
const tmdbCast = enrichedCast(details.credits);
const tmdbDirectors = enrichedDirectors(details.credits);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
@@ -163,6 +204,7 @@ export function mergeVodInfoWithTmdb(
backdrop_path: mergedBackdrops(details, info.backdrop_path),
episode_run_time: info.episode_run_time || (details.runtime ?? 0),
youtube_trailer: prefer(trailer, info.youtube_trailer),
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
@@ -175,6 +217,7 @@ export function mergeSerieInfoWithTmdb(
details: TmdbTvDetails
): XtreamSerieInfo {
const tmdbCast = enrichedCast(details.credits);
const tmdbDirectors = enrichedCreators(details);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
@@ -197,6 +240,7 @@ export function mergeSerieInfoWithTmdb(
backdrop_path: mergedBackdrops(details, info.backdrop_path),
youtube_trailer: prefer(trailer, info.youtube_trailer),
tmdb_id: details.id,
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
@@ -216,6 +260,10 @@ export function mergeStalkerInfoWithTmdb(
mediaType: TmdbMediaType
): StalkerVodInfo {
const tmdbCast = enrichedCast(details.credits);
const tmdbDirectors =
mediaType === 'movie'
? enrichedDirectors(details.credits)
: enrichedCreators(details as TmdbTvDetails);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
@@ -245,6 +293,7 @@ export function mergeStalkerInfoWithTmdb(
tmdb_id: details.id,
...(backdrop ? { tmdb_backdrop: backdrop } : {}),
...(trailer ? { tmdb_trailer: trailer } : {}),
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
+56 -4
View File
@@ -35,6 +35,30 @@ const person: TmdbPersonDetails = {
{ id: 42, title: 'No media type' },
{ id: 43, media_type: 'movie', title: ' ' },
],
crew: [
{
id: 16869,
media_type: 'movie',
title: 'Legends of the Fall',
release_date: '1994-12-16',
job: 'Producer',
},
{
id: 1422,
media_type: 'movie',
title: 'The Departed',
release_date: '2006-10-05',
job: 'Director',
},
{
// Directed a title he also starred in — acting credit wins
id: 550,
media_type: 'movie',
title: 'Fight Club',
release_date: '1999-10-15',
job: 'Director',
},
],
},
};
@@ -57,19 +81,47 @@ describe('mapPersonFilmography', () => {
const credits = mapPersonFilmography(person);
expect(credits.map((credit) => credit.tmdbId)).toEqual([
550, 1104, 999,
1422, 550, 1104, 999,
]);
expect(credits[0]).toEqual({
expect(credits[1]).toEqual({
tmdbId: 550,
mediaType: 'movie',
title: 'Fight Club',
year: 1999,
posterUrl: 'https://image.tmdb.org/t/p/w500/fc.jpg',
character: 'Tyler Durden',
crewJob: null,
});
expect(credits[1].mediaType).toBe('tv');
expect(credits[2].mediaType).toBe('tv');
// Undated entries sort last
expect(credits[2].year).toBeNull();
expect(credits[3].year).toBeNull();
});
it('includes directing credits from the crew, acting wins the dedup', () => {
const credits = mapPersonFilmography(person);
// Directed-only title carries the role separately from character,
// so the UI can render it through a translated label
const departed = credits.find((credit) => credit.tmdbId === 1422);
expect(departed).toEqual({
tmdbId: 1422,
mediaType: 'movie',
title: 'The Departed',
year: 2006,
posterUrl: null,
character: null,
crewJob: 'Director',
});
// Non-director crew jobs (Producer) are not part of the filmography
expect(
credits.find((credit) => credit.tmdbId === 16869)
).toBeUndefined();
// Fight Club was acted AND directed — the acting credit wins
const fightClub = credits.find((credit) => credit.tmdbId === 550);
expect(fightClub?.character).toBe('Tyler Durden');
expect(fightClub?.crewJob).toBeNull();
});
it('handles a person without credits', () => {
+28 -3
View File
@@ -17,16 +17,30 @@ export interface ActorProfile {
photoUrl: string | null;
}
export type ActorCrewJob = 'Director' | 'Creator';
export interface ActorFilmographyCredit {
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
year: number | null;
posterUrl: string | null;
/** Character name for acting credits, null for crew-only credits */
character: string | null;
/**
* Crew role for directing-only credits — kept separate from
* `character` so the UI can render it through translated labels
* instead of TMDB's raw English job string.
*/
crewJob: ActorCrewJob | null;
}
const MAX_FILMOGRAPHY_CREDITS = 80;
/** Crew jobs worth showing on the person page (directors, TV creators) */
const FILMOGRAPHY_CREW_JOBS = new Set<string>([
'Director',
'Creator',
] satisfies ActorCrewJob[]);
export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile {
return {
@@ -41,8 +55,11 @@ export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile {
}
/**
* Deduplicated acting credits, newest first (undated entries last),
* capped at {@link MAX_FILMOGRAPHY_CREDITS}.
* Deduplicated acting + directing credits in one merged list, newest
* first (undated entries last), capped at {@link MAX_FILMOGRAPHY_CREDITS}.
* Acting credits win the dedup so "Actor — Character" survives when a
* person both starred in and directed the same title; directing-only
* titles carry the crew role in `crewJob` for translated rendering.
*/
export function mapPersonFilmography(
person: TmdbPersonDetails
@@ -50,7 +67,14 @@ export function mapPersonFilmography(
const seen = new Set<string>();
const credits: ActorFilmographyCredit[] = [];
for (const credit of person.combined_credits?.cast ?? []) {
const castCredits = (person.combined_credits?.cast ?? []).map(
(credit) => ({ credit, crewJob: null as ActorCrewJob | null })
);
const crewCredits = (person.combined_credits?.crew ?? [])
.filter((credit) => credit.job && FILMOGRAPHY_CREW_JOBS.has(credit.job))
.map((credit) => ({ credit, crewJob: credit.job as ActorCrewJob }));
for (const { credit, crewJob } of [...castCredits, ...crewCredits]) {
const mediaType =
credit.media_type === 'movie' || credit.media_type === 'tv'
? credit.media_type
@@ -69,6 +93,7 @@ export function mapPersonFilmography(
year: extractYear(credit.release_date ?? credit.first_air_date),
posterUrl: tmdbPosterUrl(credit.poster_path),
character: credit.character?.trim() || null,
crewJob,
});
}
+16 -3
View File
@@ -45,9 +45,11 @@ export interface TmdbCastMember {
}
export interface TmdbCrewMember {
id?: number;
name: string;
job?: string;
department?: string;
profile_path?: string | null;
}
export interface TmdbCredits {
@@ -97,7 +99,11 @@ export interface TmdbTvDetails extends TmdbDetailsBase {
original_name?: string;
first_air_date?: string;
episode_run_time?: number[];
created_by?: { name: string }[];
created_by?: {
id?: number;
name: string;
profile_path?: string | null;
}[];
}
export interface TmdbEpisode {
@@ -118,7 +124,7 @@ export interface TmdbSeasonDetails {
episodes?: TmdbEpisode[];
}
/** One acting credit from /person/{id} combined_credits */
/** One credit from /person/{id} combined_credits (cast or crew) */
export interface TmdbPersonCredit {
id: number;
media_type?: string;
@@ -128,7 +134,11 @@ export interface TmdbPersonCredit {
/** TV credits */
name?: string;
first_air_date?: string;
/** Acting credits (cast array) */
character?: string;
/** Crew credits (crew array) — e.g. "Director" / "Directing" */
job?: string;
department?: string;
poster_path?: string | null;
vote_count?: number;
popularity?: number;
@@ -142,7 +152,10 @@ export interface TmdbPersonDetails {
deathday?: string | null;
place_of_birth?: string | null;
profile_path?: string | null;
combined_credits?: { cast?: TmdbPersonCredit[] };
combined_credits?: {
cast?: TmdbPersonCredit[];
crew?: TmdbPersonCredit[];
};
}
export type TmdbDetails = TmdbMovieDetails | TmdbTvDetails;
@@ -6,6 +6,7 @@ const {
createTableStatements,
indexMigrationStatements,
runMigrations,
cleanupLegacyTmdbSearchCache,
} = __databaseConnectionTestHooks;
type SqliteHandle = Parameters<typeof runMigrations>[0];
@@ -22,10 +23,7 @@ type StatementHandler = {
type HandlerRule = [pattern: string, handler: StatementHandler];
function createSqliteMock(
rules: HandlerRule[],
exec: jest.Mock = jest.fn()
) {
function createSqliteMock(rules: HandlerRule[], exec: jest.Mock = jest.fn()) {
const prepare = jest.fn((statement: string) => {
const compact = compactSql(statement);
const rule = rules.find(([pattern]) => compact.includes(pattern));
@@ -129,6 +127,45 @@ describe('runMigrations error tolerance', () => {
});
});
describe('TMDB search lookup v2 cache cleanup', () => {
it('deletes legacy search rows and records the migration atomically', () => {
const deleteRun = jest.fn();
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
['SELECT value FROM app_state', { get: () => undefined }],
['DELETE FROM tmdb_metadata', { run: deleteRun }],
['INSERT INTO app_state', { run: markerRun }],
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledWith(
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
);
const deleteSql = compactSql(
(sqlite.prepare as jest.Mock).mock.calls.find(([statement]) =>
statement.includes('DELETE FROM tmdb_metadata')
)?.[0]
);
expect(deleteSql).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
});
it('does nothing after the migration has completed', () => {
const { sqlite, prepare, transaction } = createSqliteMock([
completedMigrationStateRule,
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).not.toHaveBeenCalled();
expect(prepare).toHaveBeenCalledTimes(1);
});
});
describe('runMigrations Xtream cache deduplication', () => {
it('re-points content to the canonical duplicate category before deleting the rest', () => {
const candidatesAll = jest.fn(() => [
@@ -141,7 +178,11 @@ describe('runMigrations Xtream cache deduplication', () => {
completedMigrationStateRule,
[
'FROM categories GROUP BY playlist_id, type, xtream_id',
{ all: () => [{ playlistId: 'p1', type: 'live', xtreamId: 5 }] },
{
all: () => [
{ playlistId: 'p1', type: 'live', xtreamId: 5 },
],
},
],
['LEFT JOIN content', { all: candidatesAll }],
[
@@ -149,7 +190,10 @@ describe('runMigrations Xtream cache deduplication', () => {
{ run: updateContentRun },
],
['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }],
['FROM content GROUP BY category_id, type, xtream_id', { all: () => [] }],
[
'FROM content GROUP BY category_id, type, xtream_id',
{ all: () => [] },
],
]);
runMigrations(sqlite);
@@ -25,6 +25,39 @@ describe('database schema statements', () => {
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
} = __databaseConnectionTestHooks;
const ensureDownloadsPauseResumeSchema = (
__databaseConnectionTestHooks as unknown as {
ensureDownloadsPauseResumeSchema: (sqlite: {
prepare: (statement: string) => {
get?: () => unknown;
run?: (...args: unknown[]) => unknown;
};
transaction: (callback: () => void) => () => void;
}) => void;
}
).ensureDownloadsPauseResumeSchema;
function createRebuildSqlite(legacyTableSql: string | undefined) {
const statements: string[] = [];
const transaction = jest.fn((callback: () => void) => callback);
const prepare = jest.fn((statement: string) => {
if (statement.includes('FROM sqlite_master')) {
return {
get: () =>
legacyTableSql === undefined
? undefined
: { sql: legacyTableSql },
};
}
return {
run: () => {
statements.push(compactSql(statement));
},
};
});
return { prepare, statements, transaction };
}
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
const schemaSql = createTableStatements.map(compactSql).join('\n');
@@ -45,6 +78,9 @@ describe('database schema statements', () => {
'CREATE TABLE IF NOT EXISTS playback_positions'
);
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
expect(schemaSql).toContain('request_headers TEXT');
expect(schemaSql).toContain('resume_validator TEXT');
expect(schemaSql).toContain("'paused'");
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
);
@@ -383,6 +419,80 @@ describe('database schema statements', () => {
);
});
it('rebuilds a legacy downloads table without paused status or stored headers', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
const { statements } = sqlite;
const renameIndex = statements.findIndex((statement) =>
statement.startsWith('ALTER TABLE downloads RENAME TO')
);
const createIndex = statements.findIndex((statement) =>
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
);
const copyIndex = statements.findIndex((statement) =>
statement.startsWith('INSERT INTO downloads')
);
const dropLegacyIndex = statements.findIndex((statement) =>
statement.startsWith('DROP TABLE downloads_pause_resume_legacy')
);
expect(renameIndex).toBeGreaterThanOrEqual(0);
expect(createIndex).toBeGreaterThan(renameIndex);
expect(copyIndex).toBeGreaterThan(createIndex);
expect(dropLegacyIndex).toBeGreaterThan(copyIndex);
// Indexes on the old table are dropped before the rename and
// recreated after the copy.
expect(
statements.filter((statement) =>
statement.startsWith('DROP INDEX IF EXISTS')
).length
).toBe(3);
expect(
statements
.slice(dropLegacyIndex + 1)
.filter((statement) => statement.includes('CREATE')).length
).toBe(3);
// The rebuilt table carries the new contract; legacy rows get NULL
// stored headers, and the copy never references resume_validator so
// it defaults to NULL.
expect(statements[createIndex]).toContain(`'paused'`);
expect(statements[createIndex]).toContain('request_headers TEXT');
expect(statements[createIndex]).toContain('resume_validator TEXT');
expect(statements[copyIndex]).toContain('NULL AS request_headers');
expect(statements[copyIndex]).not.toContain('resume_validator');
});
it('copies stored headers when only the paused status is missing', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
const copy = sqlite.statements.find((statement) =>
statement.startsWith('INSERT INTO downloads')
);
expect(copy).toBeDefined();
expect(copy).not.toContain('NULL AS request_headers');
expect(copy).toContain('request_headers');
});
it('skips the downloads rebuild when the table already has the paused contract', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
expect(sqlite.transaction).not.toHaveBeenCalled();
expect(sqlite.statements).toHaveLength(0);
});
it('backfills migrated EPG program source URLs in bounded batches', () => {
const { backfillEpgProgramSourceUrls } =
__databaseConnectionTestHooks;
+199 -24
View File
@@ -30,6 +30,8 @@ const CONTENT_TITLE_FTS_MIGRATION_KEY =
'migration:content-title-fts-trigram:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
'migration:epg-program-source-url-backfill:v1';
const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
@@ -83,6 +85,34 @@ const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
fetched_at TEXT DEFAULT (datetime('now'))
)`;
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
request_headers TEXT,
resume_validator TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`;
const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
@@ -169,6 +199,7 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`,
`CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_epg_channel ON content(epg_channel_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added)`,
`CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`,
// Partial covering index for visible categories — supports the dashboard's
@@ -261,6 +292,15 @@ const CREATE_TABLE_STATEMENTS = [
INSERT INTO epg_programs_fts(rowid, title, description, category)
VALUES (new.id, new.title, new.description, new.category);
END`,
// EPG channel mappings (manual user overrides)
`CREATE TABLE IF NOT EXISTS epg_channel_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_key TEXT NOT NULL UNIQUE,
epg_channel_id TEXT NOT NULL,
playlist_id TEXT,
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id)`,
// Playback Positions table
`CREATE TABLE IF NOT EXISTS playback_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -281,30 +321,8 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`,
`CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC)`,
// Downloads table
`CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
DOWNLOADS_TABLE_SQL,
...DOWNLOADS_INDEX_STATEMENTS,
// TMDB metadata cache (details payloads + search match resolutions)
TMDB_METADATA_TABLE_SQL,
TMDB_METADATA_INDEX_SQL,
@@ -342,6 +360,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -360,9 +380,11 @@ export const __databaseConnectionTestHooks = {
createTableStatements: CREATE_TABLE_STATEMENTS,
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
ensureDownloadsPauseResumeSchema,
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
backfillEpgProgramSourceUrls,
cleanupLegacyTmdbSearchCache,
runMigrations,
} as const;
@@ -758,11 +780,164 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
}
}
/**
* Search-match cache keys gained a v2 suffix when title normalization changed.
* Remove the now-unreachable unversioned rows once rather than leaving negative
* resolutions and other legacy search matches in long-lived installations.
*/
function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
if (migrationState?.value === 'done') {
return;
}
const executeCleanup = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`DELETE FROM tmdb_metadata
WHERE lookup_key LIKE 'title:%|year:%'
AND lookup_key NOT LIKE 'title:%|year:%|v%'`
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY);
});
executeCleanup();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
);
}
}
/**
* Existing downloads tables had a status CHECK without 'paused'. SQLite cannot
* alter CHECK constraints in place, so rebuild only when the table SQL still
* reflects the old contract or lacks request_headers.
*/
function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
try {
const row = sqliteDb
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'`
)
.get() as { sql?: string } | undefined;
if (!row?.sql) {
return;
}
const hasPausedStatus = row.sql.includes(`'paused'`);
const hasRequestHeaders = row.sql.includes('request_headers');
if (hasPausedStatus && hasRequestHeaders) {
return;
}
const legacyHeadersSelect = hasRequestHeaders
? 'request_headers'
: 'NULL AS request_headers';
const rebuild = sqliteDb.transaction(() => {
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
const match = statement.match(
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+([^\s]+)\s+/i
);
if (match?.[1]) {
sqliteDb.prepare(`DROP INDEX IF EXISTS ${match[1]}`).run();
}
}
sqliteDb
.prepare(
`ALTER TABLE downloads RENAME TO downloads_pause_resume_legacy`
)
.run();
sqliteDb.prepare(DOWNLOADS_TABLE_SQL).run();
sqliteDb
.prepare(
`INSERT INTO downloads (
id,
playlist_id,
xtream_id,
content_type,
series_xtream_id,
season_number,
episode_number,
title,
url,
file_name,
file_path,
poster_url,
request_headers,
status,
bytes_downloaded,
total_bytes,
error_message,
created_at,
updated_at
)
SELECT
id,
playlist_id,
xtream_id,
content_type,
series_xtream_id,
season_number,
episode_number,
title,
url,
file_name,
file_path,
poster_url,
${legacyHeadersSelect},
status,
bytes_downloaded,
total_bytes,
error_message,
created_at,
updated_at
FROM downloads_pause_resume_legacy`
)
.run();
sqliteDb
.prepare(`DROP TABLE downloads_pause_resume_legacy`)
.run();
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
sqliteDb.prepare(statement).run();
}
});
rebuild();
console.log('[DB] Rebuilt downloads table with pause/resume schema');
} catch (error) {
console.warn('[DB] downloads pause/resume migration failed:', error);
}
}
/**
* Run migrations that may fail if already applied
*/
function runMigrations(sqliteDb: Database.Database): void {
widenTmdbMetadataMediaTypeCheck(sqliteDb);
cleanupLegacyTmdbSearchCache(sqliteDb);
ensureDownloadsPauseResumeSchema(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
deduplicateXtreamCache(sqliteDb);
@@ -0,0 +1,30 @@
/**
* Drizzle ORM schema for manual EPG-to-channel mappings.
*
* Split out of schema.ts to keep that file within the repository's
* max-lines budget; re-exported from there so `import * as schema`
* consumers keep seeing a single schema namespace.
*/
import { sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
// EPG channel mappings (manual user overrides)
export const epgChannelMappings = sqliteTable(
'epg_channel_mappings',
{
id: integer('id').primaryKey({ autoIncrement: true }),
channelKey: text('channel_key').notNull().unique(),
epgChannelId: text('epg_channel_id').notNull(),
playlistId: text('playlist_id'),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
playlistIdx: index('idx_epg_channel_mappings_playlist').on(
table.playlistId
),
})
);
export type EpgChannelMapping = typeof epgChannelMappings.$inferSelect;
export type NewEpgChannelMapping = typeof epgChannelMappings.$inferInsert;
+17 -1
View File
@@ -16,6 +16,10 @@ import {
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
// Manual EPG-to-channel mappings live in their own schema module; re-export
// them so `import * as schema from './schema'` keeps a complete namespace.
export * from './epg-mapping.schema';
// Playlists table
export const playlists = sqliteTable('playlists', {
id: text('id').primaryKey(),
@@ -110,6 +114,9 @@ export const content = sqliteTable(
categoryIdx: index('idx_content_category').on(table.categoryId),
titleIdx: index('idx_content_title').on(table.title),
xtreamIdx: index('idx_content_xtream').on(table.xtreamId),
epgChannelIdx: index('idx_content_epg_channel').on(
table.epgChannelId
),
categoryTypeXtreamUnique: uniqueIndex(
'content_category_type_xtream_unique'
).on(table.categoryId, table.type, table.xtreamId),
@@ -325,9 +332,18 @@ export const downloads = sqliteTable(
fileName: text('file_name'),
filePath: text('file_path'),
posterUrl: text('poster_url'),
requestHeaders: text('request_headers'),
resumeValidator: text('resume_validator'),
// Download progress
status: text('status', {
enum: ['queued', 'downloading', 'completed', 'failed', 'canceled'],
enum: [
'queued',
'downloading',
'paused',
'completed',
'failed',
'canceled',
],
})
.notNull()
.default('queued'),
+2
View File
@@ -8,6 +8,7 @@ export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
export * from './lib/epg-channel.model';
export * from './lib/epg-item.interface';
export * from './lib/epg-mapping-key.util';
export * from './lib/epg-program.model';
export * from './lib/external-player-arguments.utils';
export * from './lib/external-player-session.interface';
@@ -35,6 +36,7 @@ export * from './lib/store-keys.enum';
export * from './lib/stream-format.enum';
export * from './lib/catalog-title-match.interface';
export * from './lib/theme.enum';
export * from './lib/season-marker.util';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
export * from './lib/xtream-account-info-dialog-data.interface';
@@ -119,6 +119,7 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
Completed: 'completed',
Downloading: 'downloading',
Failed: 'failed',
Paused: 'paused',
Queued: 'queued',
} as const;
@@ -266,6 +267,19 @@ export interface ElectronBridgeEpgFreshnessResult {
freshUrls: string[];
}
export interface ElectronBridgeEpgMapping {
id: number;
channelKey: string;
epgChannelId: string;
playlistId: string | null;
}
export interface ElectronBridgeEpgSearchResult {
id: string;
displayName: string;
iconUrl: string | null;
}
export interface ElectronBridgeEpgLookupOptions {
sourceUrls?: string[];
}
@@ -616,6 +630,25 @@ export interface ElectronBridgeApi {
searchTerm: string,
limit?: number
) => Promise<EpgProgram[]>;
// EPG channel mapping (manual user overrides)
getEpgMapping: (
channelKey: string
) => Promise<ElectronBridgeEpgMapping | null>;
getEpgMappingsBatch: (
channelKeys: string[]
) => Promise<Record<string, string>>;
setEpgMapping: (
channelKey: string,
epgChannelId: string,
playlistId?: string
) => Promise<ElectronBridgeResult>;
deleteEpgMapping: (channelKey: string) => Promise<ElectronBridgeResult>;
searchEpgChannels: (
searchTerm: string,
limit?: number
) => Promise<ElectronBridgeEpgSearchResult[]>;
updateSettings: (settings: Partial<Settings>) => Promise<void>;
getAiSettings: () => Promise<ElectronBridgeAiSettings>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
@@ -921,6 +954,11 @@ export interface ElectronBridgeApi {
data: ElectronBridgeDownloadStartPayload
) => Promise<ElectronBridgeDownloadStartResult>;
downloadsCancel: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
downloadsPause: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
downloadsResume: (
downloadId: number,
downloadFolder: string
) => Promise<ElectronBridgeErrorResult>;
downloadsRetry: (
downloadId: number,
downloadFolder: string
@@ -37,11 +37,16 @@ export interface EmbeddedMpvSupport {
engine?: EmbeddedMpvEngine;
/**
* True when this machine could run the frame-copy engine (macOS arm64
* or Linux, with the helper binary present), regardless of whether it
* is active.
* or Linux x64, after its helper/runtime capability gate), regardless of
* whether it is active.
* Drives the Settings toggle; switching engines requires an app restart.
*/
frameCopyAvailable?: boolean;
/**
* Stable fail-closed capability reason when frameCopyAvailable is false.
* Intended for startup tracing and support diagnostics, not user copy.
*/
frameCopyUnavailableReason?: string;
}
/**
@@ -0,0 +1,38 @@
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
} from './epg-mapping-key.util';
describe('buildXtreamEpgMappingKey', () => {
it('scopes the key to the playlist', () => {
expect(buildXtreamEpgMappingKey('playlist-a', 12345)).toBe(
'xtream:playlist-a:12345'
);
});
it('produces distinct keys for the same stream id in different playlists', () => {
expect(buildXtreamEpgMappingKey('playlist-a', 12345)).not.toBe(
buildXtreamEpgMappingKey('playlist-b', 12345)
);
});
it('accepts string stream ids', () => {
expect(buildXtreamEpgMappingKey('playlist-a', '42')).toBe(
'xtream:playlist-a:42'
);
});
});
describe('buildStalkerEpgMappingKey', () => {
it('scopes the key to the playlist', () => {
expect(buildStalkerEpgMappingKey('portal-a', '205')).toBe(
'stalker:portal-a:205'
);
});
it('never collides with the Xtream key space', () => {
expect(buildStalkerEpgMappingKey('p', 1)).not.toBe(
buildXtreamEpgMappingKey('p', 1)
);
});
});
@@ -0,0 +1,28 @@
/**
* Key helpers for the manual EPG channel mapping table
* (`epg_channel_mappings`).
*
* M3U channels use their EPG lookup key (tvg-id, falling back to the
* channel name) directly — those identifiers are meaningful across
* playlists, so a mapping saved once applies everywhere.
*
* Xtream stream IDs and Stalker channel IDs, on the other hand, are
* provider-local: stream 12345 in portal A has nothing to do with
* stream 12345 in portal B. Mapping keys for portal channels therefore
* embed the playlist ID so mappings never leak across portals. Shared
* between the renderer and the Electron main process/worker, like
* `title-normalization.util.ts`.
*/
export function buildXtreamEpgMappingKey(
playlistId: string,
xtreamId: number | string
): string {
return `xtream:${playlistId}:${xtreamId}`;
}
export function buildStalkerEpgMappingKey(
playlistId: string,
stalkerId: number | string
): string {
return `stalker:${playlistId}:${stalkerId}`;
}
@@ -24,7 +24,8 @@ export interface ParsedPlaylistItem {
referrer: string;
'user-agent': string;
};
url: string;
/** absent when an #EXTINF entry has no stream URL (e.g. truncated file) */
url?: string;
raw: string;
catchup?: {
type?: string;
@@ -54,6 +54,8 @@ export interface Playlist {
recentlyViewed?: PlaylistRecentlyViewedItem[];
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
isFullStalkerPortal?: boolean;
/** Xtream server timezone string for catch-up URL construction */
serverTimezone?: string;
/** Session token for full stalker portal authentication - persisted for session */
stalkerToken?: string;
/** Serial number for stalker portal - generated once and stored for consistency */
@@ -0,0 +1,116 @@
import {
extractSeasonFromTitle,
pickSeasonMarkedTitle,
resolveEnrichmentSeasonNumber,
} from './season-marker.util';
describe('extractSeasonFromTitle', () => {
it('reads bracketed number-first markers', () => {
expect(extractSeasonFromTitle('The Mandalorian (2 season)')).toBe(2);
expect(extractSeasonFromTitle('Кухня (6 сезон)')).toBe(6);
});
it('reads word-first markers with any joiner', () => {
expect(extractSeasonFromTitle('Breaking Bad Season 2')).toBe(2);
expect(extractSeasonFromTitle('Breaking Bad season_02')).toBe(2);
expect(extractSeasonFromTitle('Breaking Bad season2')).toBe(2);
expect(extractSeasonFromTitle('Мандалорец сезон 2')).toBe(2);
expect(extractSeasonFromTitle('Dark Staffel 3')).toBe(3);
expect(extractSeasonFromTitle('La Casa Temporada 4')).toBe(4);
});
it('reads number-first markers including ordinals', () => {
expect(extractSeasonFromTitle('The Boys 2 Season')).toBe(2);
expect(extractSeasonFromTitle('The Boys 2nd Season')).toBe(2);
expect(extractSeasonFromTitle('Пацаны 2 сезон')).toBe(2);
expect(extractSeasonFromTitle('Пацаны 2-й сезон')).toBe(2);
});
it('reads S-form markers, including S..E.. episode tags', () => {
expect(extractSeasonFromTitle('The Boys S05')).toBe(5);
expect(extractSeasonFromTitle('Gintama s2')).toBe(2);
expect(extractSeasonFromTitle('[S03] Dark')).toBe(3);
expect(extractSeasonFromTitle('Dark S02E05')).toBe(2);
});
it('never fires on titles without an explicit marker', () => {
expect(extractSeasonFromTitle('The Mandalorian')).toBeNull();
expect(extractSeasonFromTitle("Ocean's 11")).toBeNull();
expect(extractSeasonFromTitle('Cars 2')).toBeNull();
expect(extractSeasonFromTitle('The 4400')).toBeNull();
expect(extractSeasonFromTitle('The Four Seasons')).toBeNull();
expect(extractSeasonFromTitle('2 Fast 2 Furious')).toBeNull();
expect(extractSeasonFromTitle('2001: A Space Odyssey')).toBeNull();
expect(extractSeasonFromTitle('')).toBeNull();
expect(extractSeasonFromTitle(null)).toBeNull();
});
it('rejects plurals, word-internal hits and season zero', () => {
expect(extractSeasonFromTitle('Best of 2 Seasons')).toBeNull();
expect(extractSeasonFromTitle('Postseason 3')).toBeNull();
expect(extractSeasonFromTitle('Dark S00')).toBeNull();
});
});
describe('pickSeasonMarkedTitle', () => {
it('prefers the title that carries a season marker', () => {
expect(
pickSeasonMarkedTitle(
'Regular Series',
'The Mandalorian (2 season)'
)
).toBe('The Mandalorian (2 season)');
expect(
pickSeasonMarkedTitle('The Boys S05', 'The Boys')
).toBe('The Boys S05');
});
it('falls back to the first non-empty title without markers', () => {
expect(pickSeasonMarkedTitle(undefined, 'The Mandalorian')).toBe(
'The Mandalorian'
);
expect(pickSeasonMarkedTitle('', null)).toBeNull();
});
});
describe('resolveEnrichmentSeasonNumber', () => {
it('overrides a renumbered single-season slice with the title season', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian (2 season)',
providerSeasonNumber: 1,
providerSeasonCount: 1,
})
).toBe(2);
});
it('keeps provider numbering for multi-season items', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian (2 season)',
providerSeasonNumber: 1,
providerSeasonCount: 3,
})
).toBe(1);
});
it('keeps provider numbering when the marker agrees with it', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian (2 season)',
providerSeasonNumber: 2,
providerSeasonCount: 1,
})
).toBe(2);
});
it('keeps provider numbering without a title marker', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian',
providerSeasonNumber: 1,
providerSeasonCount: 1,
})
).toBe(1);
});
});
@@ -0,0 +1,122 @@
/**
* Season markers in provider titles. Providers often slice a show into
* per-season catalog items ("The Mandalorian (2 season)", "Пацаны 2 сезон",
* "The Boys S05") and renumber the single contained season to 1 — the
* marker in the title is then the only source of the real season number.
* Pure functions shared by the renderer and tests — no Angular/Node deps.
*/
/**
* Season vocabulary shared with the trailing-suffix stripper in
* `title-normalization.util.ts`. Words only — digit placement (before or
* after the word) is handled by the patterns built from it.
*/
export const SEASON_WORD_ALTERNATIVES = 'season|сезон|staffel|temporada|saison';
const SEASON_WORD = `(?:${SEASON_WORD_ALTERNATIVES})`;
/**
* "Season 2", "season_02", "season2", "сезон 2". The leading guard blocks
* word-internal matches ("postseason 3"); the digit lookahead rejects
* 3+-digit numbers, which are never season markers.
*/
const WORD_FIRST_MARKER = new RegExp(
`(?:^|[^\\p{L}])${SEASON_WORD}[\\s_.-]*(\\d{1,2})(?!\\d)`,
'iu'
);
/**
* "2 season", "2nd Season", "2 сезон", "2-й сезон". The digit guard keeps
* long numbers out ("2001 season" never yields season 20/01), the trailing
* lookahead rejects plurals ("The Four Seasons", "2 Seasons").
*/
const NUMBER_FIRST_MARKER = new RegExp(
`(?:^|[^\\d])(\\d{1,2})\\s*(?:st|nd|rd|th|-?й|-?я|-?ой)?[\\s_.-]+${SEASON_WORD}(?!\\p{L})`,
'iu'
);
/**
* "S02", "s2" — and the season half of "S02E05". Tightly bounded on both
* sides so words ending in "s" followed by a number ("Ocean's 11",
* "Cars 2") can never match.
*/
const S_FORM_MARKER = /(?:^|[\s([{_.-])s(\d{1,2})(?=$|[\s)\]}_.-]|e\d)/i;
/**
* Extract an explicit season number from a RAW provider title (before any
* normalization — bracket groups like "(2 season)" are removed by
* `normalizeTitleKeys`, so this must see the original string). Returns
* `null` when there is no unambiguous marker; season 0 ("specials") is
* deliberately rejected.
*/
export function extractSeasonFromTitle(
raw: string | null | undefined
): number | null {
if (!raw) {
return null;
}
for (const pattern of [
WORD_FIRST_MARKER,
NUMBER_FIRST_MARKER,
S_FORM_MARKER,
]) {
const match = raw.match(pattern);
if (match) {
const season = Number(match[1]);
if (season >= 1) {
return season;
}
}
}
return null;
}
/**
* The first candidate title carrying an explicit season marker, else the
* first non-empty one. Providers spread the descriptive title across
* fields (a generic `name` with the marker only in `o_name`), and the
* marker must be read from whichever field carries it.
*/
export function pickSeasonMarkedTitle(
...titles: readonly (string | null | undefined)[]
): string | null {
for (const title of titles) {
if (extractSeasonFromTitle(title) !== null) {
return title ?? null;
}
}
return titles.find((title) => !!title) ?? null;
}
export interface SeasonNumberResolution {
/** RAW provider title of the series item (not normalized) */
rawTitle: string | null | undefined;
/** Season number reported by the provider's episode data */
providerSeasonNumber: number;
/** How many seasons the provider item contains in total */
providerSeasonCount: number;
}
/**
* The TMDB season number to fetch for a provider season. Provider data
* stays authoritative except for the one case it is known to lie about:
* a SINGLE-season item whose title carries a different explicit season
* marker is a per-season slice with renumbered seasons — the marker names
* the real TMDB season. Multi-season items always keep provider numbering
* (overriding all their seasons from one title marker would be wrong).
*/
export function resolveEnrichmentSeasonNumber(
resolution: SeasonNumberResolution
): number {
const fromTitle = extractSeasonFromTitle(resolution.rawTitle);
if (
fromTitle !== null &&
resolution.providerSeasonCount === 1 &&
fromTitle !== resolution.providerSeasonNumber
) {
return fromTitle;
}
return resolution.providerSeasonNumber;
}
@@ -83,6 +83,12 @@ export interface Settings {
* Missing values remain off for compatibility with older saved settings.
*/
webPlayerSharedControls?: boolean;
/**
* Fill the empty space around the inline VOD/series player with a blurred,
* dimmed copy of the poster (YouTube "Ambient mode" style) instead of plain
* black bars. Off by default; only affects the built-in web players.
*/
playerAmbientMode?: boolean;
epgUrl: string[];
streamFormat: StreamFormat;
openStreamOnDoubleClick: boolean;
@@ -3,6 +3,7 @@ export enum StalkerPortalActions {
GetGenres = 'get_genres',
CreateLink = 'create_link',
GetOrderedList = 'get_ordered_list',
GetAllChannels = 'get_all_channels',
Favorites = 'favorites',
Handshake = 'handshake',
DoAuth = 'do_auth',
@@ -39,6 +39,8 @@ export interface StalkerVodInfo {
rating_kinopoisk: string;
/** Populated by TMDB enrichment; absent in raw portal responses */
tmdb_cast?: TmdbEnrichedCastMember[];
/** Directors (movies) / creators (series) as clickable person chips */
tmdb_directors?: TmdbEnrichedCastMember[];
/** TMDB backdrop URL — Stalker portals never provide one themselves */
tmdb_backdrop?: string;
/** YouTube trailer key from TMDB — Stalker portals provide no trailers */
@@ -54,6 +54,158 @@ describe('normalizeTitleKeys', () => {
expect(normalizeTitleKeys('The Boys s05').exact).toBe('the boys');
expect(normalizeTitleKeys('Пацаны сезон 2').base).toBe('пацаны');
});
it('strips number-first season suffixes ("2 season", "2 сезон")', () => {
expect(normalizeTitle('The Mandalorian 2 Season')).toBe(
'the mandalorian'
);
expect(normalizeTitle('The Mandalorian 2nd Season')).toBe(
'the mandalorian'
);
expect(normalizeTitle('Мандалорец 2 сезон')).toBe('мандалорец');
expect(normalizeTitle('Пацаны 2-й сезон')).toBe('пацаны');
});
it('keeps plural "seasons" endings — only singular markers are tags', () => {
expect(normalizeTitle('Best of 2 Seasons')).toBe('best of 2 seasons');
});
});
describe('provider tag stripping', () => {
it('strips wrapped pipe tags', () => {
expect(normalizeTitle('|DE| ARD')).toBe('ard');
expect(normalizeTitle('|MULTI| Fallout - 4K')).toBe('fallout');
expect(normalizeTitle('|EXYU| The Pitt')).toBe('the pitt');
});
it('strips long and compound leading tags', () => {
expect(normalizeTitle('EXYU| Fallout')).toBe('fallout');
expect(normalizeTitle('MULTI| Breaking Bad')).toBe('breaking bad');
expect(normalizeTitle('4K-DE - The Pitt (2025) (US)')).toBe(
'the pitt'
);
expect(normalizeTitle('AR-SUBS - Fallout (2024) (US)')).toBe(
'fallout'
);
expect(normalizeTitle('4K-OSN+ - The Last of Us (2023)')).toBe(
'the last of us'
);
});
it('never treats numeric fragments as leading tags', () => {
expect(normalizeTitle('1917 - Behind the Lines')).toBe(
'1917 behind the lines'
);
});
it('keeps bare 4-5 char words before a spaced dash (real titles)', () => {
expect(normalizeTitle('DUNE - Part Two')).toBe('dune part two');
expect(normalizeTitle('ALIEN - Covenant')).toBe('alien covenant');
});
it('strips underscore and double-dash suffix tags', () => {
expect(normalizeTitle('Fallout_eng')).toBe('fallout');
expect(normalizeTitle('Breaking Bad (US)_msub')).toBe('breaking bad');
expect(normalizeTitle('The Pitt (2025)_sub')).toBe('the pitt');
expect(normalizeTitle('The Last of Us--esp')).toBe('the last of us');
});
it('keeps underscore-as-space titles intact', () => {
expect(normalizeTitle('The_Last_of_Us')).toBe('the last of us');
});
it('keeps sole-underscore titles whose tail is not a known tag', () => {
expect(normalizeTitle('Mr_Robot')).toBe('mr robot');
expect(normalizeTitle('Cowboy_Bebop')).toBe('cowboy bebop');
expect(normalizeTitle('Mrs_Davis')).toBe('mrs davis');
});
it('strips joined dash tags only for case-uniform vocabulary tokens', () => {
expect(normalizeTitle('Breaking Bad-eng')).toBe('breaking bad');
expect(normalizeTitle('The Last of Us-DE')).toBe('the last of us');
expect(normalizeTitle('The Pitt (2025)-it')).toBe('the pitt');
expect(normalizeTitle('Spider-Man')).toBe('spider man');
expect(normalizeTitle('Kick-It')).toBe('kick it');
});
it('keeps English hyphenated word endings that collide with codes', () => {
expect(normalizeTitle('drive-in')).toBe('drive in');
expect(normalizeTitle('Plug-in')).toBe('plug in');
});
it('strips bare trailing UPPERCASE vocabulary tags', () => {
expect(normalizeTitle('The Pitt (2025) DE')).toBe('the pitt');
expect(normalizeTitle('Breaking Bad ES')).toBe('breaking bad');
expect(normalizeTitle('EN| Breaking Bad SUB')).toBe('breaking bad');
expect(normalizeTitle('The Last of Us (2023) AF')).toBe(
'the last of us'
);
});
it('never strips trailing tags that could be real endings', () => {
expect(normalizeTitle('Rocky II')).toBe('rocky ii');
expect(normalizeTitle('Made in USA')).toBe('made in usa');
expect(normalizeTitle('NCIS: LA')).toBe('ncis la');
expect(normalizeTitle('Making It')).toBe('making it');
expect(normalizeTitle('THE LAST OF US')).toBe('the last of us');
});
const pittCorpus = [
'The Pitt (2025)_sub', 'The Pitt (2025)-it', 'The Pitt (2025)',
'The Pitt (Hindi)', 'The Pitt (2025) 4K', 'The Pitt (2025) DE',
'The Pitt (2025) ES', 'The Pitt (2025) FR', 'The Pitt (2025)_eng',
'The Pitt [MULTI-SUB]', 'The Pitt (2025) (4K DV)', 'GR - The Pitt',
'4K-DE - The Pitt (2025) (US)', '4K-TR - The Pitt (2025) (US)',
'AR-SUBS - The Pitt (2025) (US)', 'DE - The Pitt (2025) (US)',
'ALB| The Pitt', 'EXYU| The Pitt', '|ALB| The Pitt', '|DE| The Pitt',
];
const falloutCorpus = [
'Fallout', 'DE - Fallout (2024)', 'Fallout (2024) - 4K',
'Fallout (2024) FR-EN', 'Fallout (2024) Multi', 'Fallout (2024)_fr',
'Fallout_esp', 'Fallout (4K)', '4K-AMZ - Fallout (2024) (US)',
'AL - Fallout (2024)', 'AMZ - Fallout (2024) (US)',
'AR-DE - Fallout (US)', 'LA - Fallout', 'EN| Fallout - 4K',
'MULTI| Fallout - 4K', 'Fallout ( مدبلج )', 'Fallout (Telugu)',
'|EN| Fallout - 4K', '|MULTI| Fallout', '|TR| Fallout',
];
const lastOfUsCorpus = [
'The Last of Us', 'The Last Of Us', 'The Last of Us (2023) 4K',
'The Last of Us (2023) AF', 'The Last of Us_tr',
'The Last of Us--esp', 'The Last of Us-DE', 'The Last of Us-esp',
'The Last of Us [L]', 'The Last of Us ( HD )',
'4K-OSN+ - The Last of Us (2023)', 'IS - The Last of Us (2023) (US)',
'RU - The Last of Us', 'ALB| The Last of Us',
];
const breakingBadCorpus = [
'Breaking Bad', 'Breaking Bad (2008)_fr', 'Breaking Bad (US)_msub',
'Breaking Bad_it', 'Breaking Bad-DE', 'Breaking Bad-eng',
'Breaking Bad ( عائلي )', 'Breaking Bad (Pure)',
'Breaking Bad - Multi', 'Breaking Bad ES', 'AR-DE - Breaking Bad',
'EN| Breaking Bad SUB', 'MULTI| Breaking Bad', 'AR| Breaking Bad',
];
it.each([
['the pitt', pittCorpus],
['fallout', falloutCorpus],
['the last of us', lastOfUsCorpus],
['breaking bad', breakingBadCorpus],
])(
'normalizes every observed provider variant of "%s" to one key',
(expected, corpus) => {
for (const name of corpus) {
expect(normalizeTitleKeys(name).base).toBe(expected);
}
}
);
it('keeps localized subtitles (indistinguishable from real ones)', () => {
expect(normalizeTitle('Breaking Bad: A Química do Mal')).toBe(
'breaking bad a quimica do mal'
);
});
});
describe('titleYearsCompatible', () => {
Loaded 100 of 425 files, more files were not shown because too many files have changed in this diff. Show more