mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
+6
@@ -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(),
|
||||
|
||||
+33
-1
@@ -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),
|
||||
};
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+81
-2
@@ -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"
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
(channelSelected)="onChannelSelected($event)"
|
||||
(playbackRequested)="onChannelPlaybackRequested($event)"
|
||||
(channelsReordered)="onReorder($event)"
|
||||
(epgMappingChanged)="onEpgMappingChanged()"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(removeRequested)="onRemoveRequested($event)"
|
||||
/>
|
||||
|
||||
+104
@@ -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);
|
||||
|
||||
+117
-16
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+293
@@ -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);
|
||||
});
|
||||
});
|
||||
+177
-4
@@ -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 });
|
||||
},
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
+89
@@ -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(
|
||||
|
||||
+161
-3
@@ -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,
|
||||
|
||||
+72
@@ -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;
|
||||
}
|
||||
+123
@@ -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();
|
||||
});
|
||||
});
|
||||
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
-8
@@ -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>
|
||||
+38
@@ -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;
|
||||
}
|
||||
+380
-10
@@ -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 () => {
|
||||
|
||||
+300
-12
@@ -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) {
|
||||
|
||||
+7
-12
@@ -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
|
||||
);
|
||||
}
|
||||
+158
@@ -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');
|
||||
});
|
||||
});
|
||||
+93
-27
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
-7
@@ -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()"
|
||||
|
||||
+235
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
+66
-6
@@ -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
|
||||
|
||||
+18
@@ -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>
|
||||
|
||||
+8
@@ -171,3 +171,11 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-context-menu-anchor {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
+116
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
Reference in new issue
Block a user