mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
feat(xtream): fall back to uploaded XMLTV when provider has no EPG
Live TV channels in Xtream playlists go blank whenever the provider's get_short_epg returns nothing, even when the user has working XMLTV URLs in Settings. The XMLTV pipeline already populates epg_programs but only the M3U module was reading it. Wire those uploads up as a fallback for Xtream too: when the provider returns nothing for a channel with an epg_channel_id, look it up in the local table. A settings toggle flips the priority for users whose curated XMLTV is better than the provider's auto guide. EpgQueueService.enqueue() became async to batch the XMLTV lookup once per viewport change. To keep the queue consistent under fast scroll, all shared-state mutations now happen behind a generation counter so only the latest call commits. Per-method bridge gating in the new XtreamXmltvFallbackService keeps each path working when the preload exposes only one of the two endpoints.
This commit is contained in:
32 files changed
+1002
-133
No files matched your search
@@ -1,10 +1,18 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SettingsStore } from 'services';
|
||||
import { EpgQueueService } from './epg-queue.service';
|
||||
import { XtreamApiService } from './xtream-api.service';
|
||||
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
|
||||
import type { EpgItem } from 'shared-interfaces';
|
||||
|
||||
describe('EpgQueueService', () => {
|
||||
let service: EpgQueueService;
|
||||
let xtreamApi: { getShortEpg: jest.Mock };
|
||||
let fallback: {
|
||||
getProgramsForChannel: jest.Mock;
|
||||
getCurrentProgramsBatch: jest.Mock;
|
||||
};
|
||||
let settings: { preferUploadedEpgOverXtream: jest.Mock };
|
||||
|
||||
const credentials = {
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
@@ -12,20 +20,32 @@ describe('EpgQueueService', () => {
|
||||
password: 'pass',
|
||||
};
|
||||
|
||||
type ServicePrivates = {
|
||||
fetchEpg: (
|
||||
credentials: typeof credentials,
|
||||
streamId: number
|
||||
) => Promise<void>;
|
||||
shouldFetch: (streamId: number) => boolean;
|
||||
xmltvPreviewByStreamId: Map<number, EpgItem>;
|
||||
epgChannelByStreamId: Map<number, string>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
xtreamApi = {
|
||||
getShortEpg: jest.fn(),
|
||||
xtreamApi = { getShortEpg: jest.fn() };
|
||||
fallback = {
|
||||
getProgramsForChannel: jest.fn().mockResolvedValue([]),
|
||||
getCurrentProgramsBatch: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
settings = { preferUploadedEpgOverXtream: jest.fn(() => false) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EpgQueueService,
|
||||
{
|
||||
provide: XtreamApiService,
|
||||
useValue: xtreamApi,
|
||||
},
|
||||
{ provide: XtreamApiService, useValue: xtreamApi },
|
||||
{ provide: XtreamXmltvFallbackService, useValue: fallback },
|
||||
{ provide: SettingsStore, useValue: settings },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -36,50 +56,253 @@ describe('EpgQueueService', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function priv(): ServicePrivates {
|
||||
return service as unknown as ServicePrivates;
|
||||
}
|
||||
|
||||
function seedXmltvPreview(streamId: number, channelId: string): EpgItem {
|
||||
const item = makeEpgItem(channelId, `${channelId} now`);
|
||||
priv().xmltvPreviewByStreamId.set(streamId, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
it('caches empty EPG responses and does not immediately refetch them', async () => {
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
|
||||
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
|
||||
credentials,
|
||||
101
|
||||
);
|
||||
await priv().fetchEpg(credentials, 101);
|
||||
|
||||
expect(service.getCached(101)).toEqual([]);
|
||||
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
|
||||
credentials,
|
||||
101,
|
||||
3,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
expect(
|
||||
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
|
||||
101
|
||||
)
|
||||
).toBe(false);
|
||||
expect(priv().shouldFetch(101)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies a cooldown after EPG request failures', async () => {
|
||||
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
|
||||
|
||||
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
|
||||
credentials,
|
||||
202
|
||||
);
|
||||
await priv().fetchEpg(credentials, 202);
|
||||
|
||||
expect(
|
||||
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
|
||||
202
|
||||
)
|
||||
).toBe(false);
|
||||
expect(priv().shouldFetch(202)).toBe(false);
|
||||
|
||||
jest.advanceTimersByTime(60_001);
|
||||
|
||||
expect(
|
||||
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
|
||||
202
|
||||
expect(priv().shouldFetch(202)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to pre-fetched XMLTV when Xtream returns empty', async () => {
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
const xmltvItem = seedXmltvPreview(303, 'rtl.de');
|
||||
|
||||
await priv().fetchEpg(credentials, 303);
|
||||
|
||||
expect(service.getCached(303)).toEqual([xmltvItem]);
|
||||
expect(fallback.getProgramsForChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not consult XMLTV when Xtream already returned programs', async () => {
|
||||
const apiItems = [makeEpgItem('rtl.de', 'Punkt 12')];
|
||||
xtreamApi.getShortEpg.mockResolvedValue(apiItems);
|
||||
seedXmltvPreview(404, 'rtl.de');
|
||||
|
||||
await priv().fetchEpg(credentials, 404);
|
||||
|
||||
expect(service.getCached(404)).toEqual(apiItems);
|
||||
});
|
||||
|
||||
it('skips XMLTV when Xtream is empty AND no XMLTV preview was prefetched', async () => {
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
|
||||
await priv().fetchEpg(credentials, 505);
|
||||
|
||||
expect(service.getCached(505)).toEqual([]);
|
||||
expect(fallback.getCurrentProgramsBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batch-prefetches XMLTV for entries with epgChannelId on enqueue', async () => {
|
||||
fallback.getCurrentProgramsBatch.mockResolvedValue({
|
||||
'rtl.de': makeEpgItem('rtl.de', 'Tagesschau'),
|
||||
'sat1.de': makeEpgItem('sat1.de', 'Akte'),
|
||||
});
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
|
||||
await service.enqueue(
|
||||
[
|
||||
{ streamId: 1, epgChannelId: 'rtl.de' },
|
||||
{ streamId: 2, epgChannelId: 'sat1.de' },
|
||||
{ streamId: 3 },
|
||||
],
|
||||
new Set([1, 2, 3]),
|
||||
credentials
|
||||
);
|
||||
|
||||
expect(fallback.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
|
||||
expect(fallback.getCurrentProgramsBatch).toHaveBeenCalledWith([
|
||||
'rtl.de',
|
||||
'sat1.de',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses XMLTV directly when the user prefers uploaded EPG', async () => {
|
||||
fallback.getCurrentProgramsBatch.mockResolvedValue({
|
||||
'rtl.de': makeEpgItem('rtl.de', 'Tagesschau (XMLTV)'),
|
||||
});
|
||||
settings.preferUploadedEpgOverXtream.mockReturnValue(true);
|
||||
|
||||
await service.enqueue(
|
||||
[{ streamId: 707, epgChannelId: 'rtl.de' }],
|
||||
new Set([707]),
|
||||
credentials
|
||||
);
|
||||
|
||||
expect(xtreamApi.getShortEpg).not.toHaveBeenCalled();
|
||||
const cached = service.getCached(707);
|
||||
expect(cached?.[0].title).toBe('Tagesschau (XMLTV)');
|
||||
});
|
||||
|
||||
it('only the latest enqueue commits queue state when prefetches overlap', async () => {
|
||||
// Defer batch resolutions so we can interleave them.
|
||||
let resolveA: (v: Record<string, EpgItem>) => void = () => {};
|
||||
let resolveB: (v: Record<string, EpgItem>) => void = () => {};
|
||||
fallback.getCurrentProgramsBatch
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Record<string, EpgItem>>((res) => {
|
||||
resolveA = res;
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Record<string, EpgItem>>((res) => {
|
||||
resolveB = res;
|
||||
})
|
||||
);
|
||||
|
||||
// preferUploaded=true makes the commit path call recordSuccess
|
||||
// directly when XMLTV has a hit, so a stale older commit would
|
||||
// be observable as an unwanted emit.
|
||||
settings.preferUploadedEpgOverXtream.mockReturnValue(true);
|
||||
|
||||
const events: number[] = [];
|
||||
const sub = service.epgResult$.subscribe(({ streamId }) =>
|
||||
events.push(streamId)
|
||||
);
|
||||
|
||||
// A: streams 11, 12 — both with epgChannelId so both would emit
|
||||
// via XMLTV-first path if A's commit ran.
|
||||
const enqueueA = service.enqueue(
|
||||
[
|
||||
{ streamId: 11, epgChannelId: 'a-only-1' },
|
||||
{ streamId: 12, epgChannelId: 'a-only-2' },
|
||||
],
|
||||
new Set([11, 12]),
|
||||
credentials
|
||||
);
|
||||
// B: streams 21 only.
|
||||
const enqueueB = service.enqueue(
|
||||
[{ streamId: 21, epgChannelId: 'b-only' }],
|
||||
new Set([21]),
|
||||
credentials
|
||||
);
|
||||
|
||||
// Resolve B first — it's the latest, should commit.
|
||||
resolveB({ 'b-only': makeEpgItem('b-only', 'B-current') });
|
||||
await enqueueB;
|
||||
|
||||
// Then resolve A — older, should bail without touching state.
|
||||
resolveA({
|
||||
'a-only-1': makeEpgItem('a-only-1', 'A1-current'),
|
||||
'a-only-2': makeEpgItem('a-only-2', 'A2-current'),
|
||||
});
|
||||
await enqueueA;
|
||||
|
||||
expect(events).toEqual([21]);
|
||||
expect(service.getCached(21)?.[0].title).toBe('B-current');
|
||||
expect(service.getCached(11)).toBeNull();
|
||||
expect(service.getCached(12)).toBeNull();
|
||||
expect(priv().xmltvPreviewByStreamId.has(11)).toBe(false);
|
||||
expect(priv().xmltvPreviewByStreamId.has(12)).toBe(false);
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('clones the caller visibleIds Set so external mutation is harmless', async () => {
|
||||
const visible = new Set([1, 2, 3]);
|
||||
await service.enqueue(
|
||||
[{ streamId: 1, epgChannelId: 'a' }],
|
||||
visible,
|
||||
credentials
|
||||
);
|
||||
|
||||
visible.delete(1);
|
||||
visible.delete(2);
|
||||
|
||||
// Internal visible set must still contain 1.
|
||||
const internal = (
|
||||
service as unknown as { visibleSet: Set<number> }
|
||||
).visibleSet;
|
||||
expect(internal.has(1)).toBe(true);
|
||||
expect(internal.has(2)).toBe(true);
|
||||
expect(internal.has(3)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears stale XMLTV preview when a re-enqueue removes the channel id', async () => {
|
||||
// First enqueue: stream 100 has a hit.
|
||||
fallback.getCurrentProgramsBatch.mockResolvedValueOnce({
|
||||
'rtl.de': makeEpgItem('rtl.de', 'Tagesschau'),
|
||||
});
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
|
||||
await service.enqueue(
|
||||
[{ streamId: 100, epgChannelId: 'rtl.de' }],
|
||||
new Set([100]),
|
||||
credentials
|
||||
);
|
||||
expect(priv().xmltvPreviewByStreamId.get(100)?.title).toBe('Tagesschau');
|
||||
|
||||
// Second enqueue: same stream 100 but no longer has an epgChannelId.
|
||||
// Batch isn't called (no ids to fetch); preview must be cleared.
|
||||
fallback.getCurrentProgramsBatch.mockResolvedValueOnce({});
|
||||
await service.enqueue(
|
||||
[{ streamId: 100, epgChannelId: undefined }],
|
||||
new Set([100]),
|
||||
credentials
|
||||
);
|
||||
|
||||
expect(priv().xmltvPreviewByStreamId.has(100)).toBe(false);
|
||||
expect(priv().epgChannelByStreamId.has(100)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not re-emit on empty→empty transitions', async () => {
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
const events: number[] = [];
|
||||
const sub = service.epgResult$.subscribe(({ streamId }) =>
|
||||
events.push(streamId)
|
||||
);
|
||||
|
||||
await priv().fetchEpg(credentials, 808);
|
||||
await priv().fetchEpg(credentials, 808);
|
||||
|
||||
expect(events).toEqual([808]);
|
||||
sub.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
function makeEpgItem(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',
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { inject, Injectable, OnDestroy } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { SettingsStore } from 'services';
|
||||
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
||||
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -9,23 +11,50 @@ interface CacheEntry {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stream metadata supplied at enqueue time. The `epgChannelId` is the
|
||||
* key used to look the channel up in the locally-parsed XMLTV when the
|
||||
* Xtream provider returns no programs for that stream.
|
||||
*/
|
||||
export interface EpgQueueEntry {
|
||||
streamId: number;
|
||||
epgChannelId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled EPG request queue with concurrency control, inter-request
|
||||
* delay, and in-memory caching. Prevents Xtream providers from
|
||||
* rate-limiting / banning the client when scrolling through large
|
||||
* channel lists.
|
||||
*
|
||||
* On each `enqueue()`, the service first batch-fetches the locally
|
||||
* parsed XMLTV current-program for every entry that has an
|
||||
* `epgChannelId` (one IPC, one SQL query). Hits are used immediately,
|
||||
* either as the answer (when `preferUploadedEpgOverXtream` is on) or as
|
||||
* a fallback when the per-stream Xtream API call returns no programs.
|
||||
*
|
||||
* Because the XMLTV pre-fetch is async, two overlapping `enqueue` calls
|
||||
* (e.g. fast viewport scroll) could otherwise interleave and let an
|
||||
* older call commit stale queue state. A generation counter gates every
|
||||
* shared-state write behind a "still latest" check so only the most
|
||||
* recent call's results are applied.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EpgQueueService implements OnDestroy {
|
||||
private readonly apiService = inject(XtreamApiService);
|
||||
private readonly fallbackService = inject(XtreamXmltvFallbackService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly logger = createLogger('EpgQueueService');
|
||||
private readonly previewLimit = 3;
|
||||
|
||||
private readonly cache = new Map<number, CacheEntry>();
|
||||
private queue: number[] = [];
|
||||
private readonly inFlight = new Set<number>();
|
||||
private readonly epgChannelByStreamId = new Map<number, string>();
|
||||
private readonly xmltvPreviewByStreamId = new Map<number, EpgItem>();
|
||||
private visibleSet = new Set<number>();
|
||||
private processing = false;
|
||||
private enqueueGeneration = 0;
|
||||
private readonly failureTimestamps = new Map<number, number>();
|
||||
|
||||
private readonly maxConcurrency = 2;
|
||||
@@ -36,7 +65,6 @@ export class EpgQueueService implements OnDestroy {
|
||||
/** Emits EPG results as they arrive. */
|
||||
readonly epgResult$ = new Subject<{ streamId: number; items: EpgItem[] }>();
|
||||
|
||||
/** Return cached EPG items if still valid, otherwise null. */
|
||||
getCached(streamId: number): EpgItem[] | null {
|
||||
const entry = this.cache.get(streamId);
|
||||
if (!entry) return null;
|
||||
@@ -72,35 +100,107 @@ export class EpgQueueService implements OnDestroy {
|
||||
/**
|
||||
* Enqueue stream IDs for EPG fetching.
|
||||
*
|
||||
* - IDs already cached or in-flight are skipped.
|
||||
* - The internal queue is replaced so that stale IDs (no longer in
|
||||
* `visibleIds`) are dropped on next dequeue.
|
||||
* Accepts the legacy `number[]` shape (without `epgChannelId`) for
|
||||
* backward compatibility — those entries skip the XMLTV fallback.
|
||||
*/
|
||||
enqueue(
|
||||
streamIds: number[],
|
||||
async enqueue(
|
||||
streams: ReadonlyArray<EpgQueueEntry | number>,
|
||||
visibleIds: Set<number>,
|
||||
credentials: XtreamCredentials
|
||||
): void {
|
||||
this.visibleSet = visibleIds;
|
||||
): Promise<void> {
|
||||
const generation = ++this.enqueueGeneration;
|
||||
|
||||
const toFetch = streamIds.filter(
|
||||
(id) => this.shouldFetch(id)
|
||||
const normalized: EpgQueueEntry[] = streams.map((entry) =>
|
||||
typeof entry === 'number' ? { streamId: entry } : { ...entry }
|
||||
);
|
||||
|
||||
// Replace the queue – any previously queued but now-invisible IDs
|
||||
// will be skipped during processing via the visibleSet check.
|
||||
this.queue = toFetch;
|
||||
const streamsByEpgId = new Map<string, number[]>();
|
||||
for (const entry of normalized) {
|
||||
const id = entry.epgChannelId?.trim();
|
||||
if (!id) continue;
|
||||
const list = streamsByEpgId.get(id) ?? [];
|
||||
list.push(entry.streamId);
|
||||
streamsByEpgId.set(id, list);
|
||||
}
|
||||
|
||||
const batchResult = await this.fetchXmltvCurrentPure(
|
||||
Array.from(streamsByEpgId.keys())
|
||||
);
|
||||
|
||||
if (generation !== this.enqueueGeneration) return;
|
||||
|
||||
// Atomic commit block — no awaits below.
|
||||
// Clone the caller's Set so external mutation cannot shift our state.
|
||||
this.visibleSet = new Set(visibleIds);
|
||||
this.pruneEphemeralMaps(this.visibleSet);
|
||||
|
||||
for (const entry of normalized) {
|
||||
const id = entry.epgChannelId?.trim();
|
||||
if (id) {
|
||||
this.epgChannelByStreamId.set(entry.streamId, id);
|
||||
} else {
|
||||
this.epgChannelByStreamId.delete(entry.streamId);
|
||||
}
|
||||
this.xmltvPreviewByStreamId.delete(entry.streamId);
|
||||
}
|
||||
for (const [epgChannelId, item] of Object.entries(batchResult)) {
|
||||
const streams = streamsByEpgId.get(epgChannelId) ?? [];
|
||||
for (const streamId of streams) {
|
||||
this.xmltvPreviewByStreamId.set(streamId, item);
|
||||
}
|
||||
}
|
||||
|
||||
const preferUploaded =
|
||||
this.settingsStore.preferUploadedEpgOverXtream?.() ?? false;
|
||||
|
||||
const ids: number[] = [];
|
||||
for (const entry of normalized) {
|
||||
if (!this.shouldFetch(entry.streamId)) continue;
|
||||
|
||||
if (preferUploaded) {
|
||||
const xmltv = this.xmltvPreviewByStreamId.get(entry.streamId);
|
||||
if (xmltv) {
|
||||
this.recordSuccess(entry.streamId, [xmltv]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ids.push(entry.streamId);
|
||||
}
|
||||
|
||||
this.queue = ids;
|
||||
|
||||
if (!this.processing) {
|
||||
this.processQueue(credentials);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure XMLTV batch fetch. Runs the IPC and returns the result without
|
||||
* mutating any shared state — keeping the await out of the commit
|
||||
* path so an older overlapping enqueue cannot pollute the maps after
|
||||
* a newer one has already committed.
|
||||
*/
|
||||
private async fetchXmltvCurrentPure(
|
||||
epgChannelIds: ReadonlyArray<string>
|
||||
): Promise<Record<string, EpgItem>> {
|
||||
if (epgChannelIds.length === 0) return {};
|
||||
return this.fallbackService.getCurrentProgramsBatch(epgChannelIds);
|
||||
}
|
||||
|
||||
private pruneEphemeralMaps(visibleIds: Set<number>): void {
|
||||
for (const id of this.epgChannelByStreamId.keys()) {
|
||||
if (!visibleIds.has(id) && !this.cache.has(id)) {
|
||||
this.epgChannelByStreamId.delete(id);
|
||||
this.xmltvPreviewByStreamId.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue(credentials: XtreamCredentials): Promise<void> {
|
||||
this.processing = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
// Wait until a concurrency slot is available
|
||||
if (this.inFlight.size >= this.maxConcurrency) {
|
||||
await this.delay(this.delayMs);
|
||||
continue;
|
||||
@@ -111,16 +211,12 @@ export class EpgQueueService implements OnDestroy {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop stale entries that are no longer visible
|
||||
if (!this.visibleSet.has(streamId)) continue;
|
||||
|
||||
// Skip if it got cached or cooled down while queued (e.g. duplicate/failure)
|
||||
if (!this.shouldFetch(streamId)) continue;
|
||||
|
||||
this.inFlight.add(streamId);
|
||||
this.fetchEpg(credentials, streamId);
|
||||
|
||||
// Space out requests
|
||||
await this.delay(this.delayMs);
|
||||
}
|
||||
|
||||
@@ -132,20 +228,20 @@ export class EpgQueueService implements OnDestroy {
|
||||
streamId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const items = await this.apiService.getShortEpg(
|
||||
const apiItems = await this.apiService.getShortEpg(
|
||||
credentials,
|
||||
streamId,
|
||||
this.previewLimit,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
this.cache.set(streamId, {
|
||||
data: items,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.failureTimestamps.delete(streamId);
|
||||
this.epgResult$.next({ streamId, items });
|
||||
|
||||
if (apiItems.length > 0) {
|
||||
this.recordSuccess(streamId, apiItems);
|
||||
return;
|
||||
}
|
||||
|
||||
const xmltv = this.xmltvPreviewByStreamId.get(streamId);
|
||||
this.recordSuccess(streamId, xmltv ? [xmltv] : []);
|
||||
} catch (error) {
|
||||
this.failureTimestamps.set(streamId, Date.now());
|
||||
this.logger.error(
|
||||
@@ -157,6 +253,17 @@ export class EpgQueueService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private recordSuccess(streamId: number, items: EpgItem[]): void {
|
||||
const previous = this.cache.get(streamId)?.data;
|
||||
this.cache.set(streamId, { data: items, timestamp: Date.now() });
|
||||
this.failureTimestamps.delete(streamId);
|
||||
|
||||
if (previous && previous.length === 0 && items.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.epgResult$.next({ streamId, items });
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './favorite-item.interface';
|
||||
export * from './favorites.service';
|
||||
export * from './xtream-api.service';
|
||||
export * from './xtream-url.service';
|
||||
export * from './xtream-xmltv-fallback.service';
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('XtreamXmltvFallbackService', () => {
|
||||
type Bridge = {
|
||||
getChannelPrograms: jest.Mock<Promise<EpgProgram[]>, [string]>;
|
||||
getCurrentProgramsBatch: jest.Mock<
|
||||
Promise<Record<string, EpgProgram | null>>,
|
||||
[string[]]
|
||||
>;
|
||||
};
|
||||
|
||||
let bridge: Bridge;
|
||||
const originalElectron = (window as { electron?: unknown }).electron;
|
||||
|
||||
beforeEach(() => {
|
||||
bridge = {
|
||||
getChannelPrograms: jest.fn(),
|
||||
getCurrentProgramsBatch: jest.fn(),
|
||||
};
|
||||
(window as { electron?: Bridge }).electron = bridge;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalElectron === undefined) {
|
||||
delete (window as { electron?: unknown }).electron;
|
||||
} else {
|
||||
(window as { electron?: unknown }).electron = originalElectron;
|
||||
}
|
||||
});
|
||||
|
||||
function makeService(): XtreamXmltvFallbackService {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [XtreamXmltvFallbackService],
|
||||
});
|
||||
return TestBed.inject(XtreamXmltvFallbackService);
|
||||
}
|
||||
|
||||
it('returns [] when window.electron bridge is unavailable', async () => {
|
||||
delete (window as { electron?: unknown }).electron;
|
||||
const service = makeService();
|
||||
|
||||
await expect(service.getProgramsForChannel('rtl.de')).resolves.toEqual(
|
||||
[]
|
||||
);
|
||||
await expect(
|
||||
service.getCurrentProgramsBatch(['rtl.de'])
|
||||
).resolves.toEqual({});
|
||||
expect(bridge.getChannelPrograms).not.toHaveBeenCalled();
|
||||
expect(bridge.getCurrentProgramsBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps EpgProgram rows to EpgItem with timestamp pairs preserved', async () => {
|
||||
const service = makeService();
|
||||
const program: EpgProgram = {
|
||||
channel: 'rtl.de',
|
||||
start: '2026-05-07T08:00:00Z',
|
||||
stop: '2026-05-07T09:00:00Z',
|
||||
startTimestamp: 1778155200,
|
||||
stopTimestamp: 1778158800,
|
||||
title: 'Tagesschau',
|
||||
desc: 'News',
|
||||
category: null,
|
||||
};
|
||||
bridge.getChannelPrograms.mockResolvedValue([program]);
|
||||
|
||||
const items = await service.getProgramsForChannel('rtl.de');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
channel_id: 'rtl.de',
|
||||
title: 'Tagesschau',
|
||||
description: 'News',
|
||||
start: '2026-05-07T08:00:00Z',
|
||||
stop: '2026-05-07T09:00:00Z',
|
||||
end: '2026-05-07T09:00:00Z',
|
||||
start_timestamp: '1778155200',
|
||||
stop_timestamp: '1778158800',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives timestamps from ISO strings when startTimestamp is missing', async () => {
|
||||
const service = makeService();
|
||||
bridge.getChannelPrograms.mockResolvedValue([
|
||||
{
|
||||
channel: 'rtl.de',
|
||||
start: '2026-05-07T08:00:00Z',
|
||||
stop: '2026-05-07T09:00:00Z',
|
||||
title: 'X',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const items = await service.getProgramsForChannel('rtl.de');
|
||||
|
||||
expect(Number(items[0].start_timestamp)).toBe(
|
||||
Math.floor(Date.parse('2026-05-07T08:00:00Z') / 1000)
|
||||
);
|
||||
expect(Number(items[0].stop_timestamp)).toBe(
|
||||
Math.floor(Date.parse('2026-05-07T09:00:00Z') / 1000)
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty / nullish channel IDs in batch lookup', async () => {
|
||||
const service = makeService();
|
||||
bridge.getCurrentProgramsBatch.mockResolvedValue({
|
||||
'rtl.de': {
|
||||
channel: 'rtl.de',
|
||||
start: '2026-05-07T08:00:00Z',
|
||||
stop: '2026-05-07T09:00:00Z',
|
||||
title: 'Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getCurrentProgramsBatch([
|
||||
'rtl.de',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
' ',
|
||||
]);
|
||||
|
||||
expect(bridge.getCurrentProgramsBatch).toHaveBeenCalledWith(['rtl.de']);
|
||||
expect(Object.keys(result)).toEqual(['rtl.de']);
|
||||
});
|
||||
|
||||
it('returns [] when the bridge throws', async () => {
|
||||
const service = makeService();
|
||||
bridge.getChannelPrograms.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.getProgramsForChannel('rtl.de')
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('uses getChannelPrograms even when getCurrentProgramsBatch is missing', async () => {
|
||||
(window as { electron?: unknown }).electron = {
|
||||
getChannelPrograms: bridge.getChannelPrograms,
|
||||
};
|
||||
const service = makeService();
|
||||
bridge.getChannelPrograms.mockResolvedValue([
|
||||
{
|
||||
channel: 'rtl.de',
|
||||
start: '2026-05-07T08:00:00Z',
|
||||
stop: '2026-05-07T09:00:00Z',
|
||||
title: 'Tagesschau',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const items = await service.getProgramsForChannel('rtl.de');
|
||||
const batch = await service.getCurrentProgramsBatch(['rtl.de']);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(batch).toEqual({});
|
||||
});
|
||||
|
||||
it('uses getCurrentProgramsBatch even when getChannelPrograms is missing', async () => {
|
||||
(window as { electron?: unknown }).electron = {
|
||||
getCurrentProgramsBatch: bridge.getCurrentProgramsBatch,
|
||||
};
|
||||
const service = makeService();
|
||||
bridge.getCurrentProgramsBatch.mockResolvedValue({
|
||||
'rtl.de': {
|
||||
channel: 'rtl.de',
|
||||
start: '2026-05-07T08:00:00Z',
|
||||
stop: '2026-05-07T09:00:00Z',
|
||||
title: 'Now',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
});
|
||||
|
||||
const items = await service.getProgramsForChannel('rtl.de');
|
||||
const batch = await service.getCurrentProgramsBatch(['rtl.de']);
|
||||
|
||||
expect(items).toEqual([]);
|
||||
expect(Object.keys(batch)).toEqual(['rtl.de']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { EpgItem, EpgProgram } from 'shared-interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
type ElectronEpgBridge = {
|
||||
getChannelPrograms?: (channelId: string) => Promise<EpgProgram[]>;
|
||||
getCurrentProgramsBatch?: (
|
||||
channelIds: string[]
|
||||
) => Promise<Record<string, EpgProgram | null>>;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class XtreamXmltvFallbackService {
|
||||
private readonly logger = createLogger('XtreamXmltvFallback');
|
||||
|
||||
/**
|
||||
* `DataService.isElectron` is intentionally not consulted here: it
|
||||
* checks `window.process?.type`, which contextBridge isolation hides
|
||||
* from the renderer. Each method below gates on the specific bridge
|
||||
* function it needs, so a partial preload (e.g. `getChannelPrograms`
|
||||
* present but `getCurrentProgramsBatch` missing) only disables the
|
||||
* affected path.
|
||||
*/
|
||||
private get bridge(): ElectronEpgBridge | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const candidate = (window as unknown as { electron?: ElectronEpgBridge })
|
||||
.electron;
|
||||
return candidate ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full schedule for a channel from local XMLTV, mapped into
|
||||
* the `EpgItem` shape the Xtream UI consumes. Returns `[]` when nothing
|
||||
* is found or the bridge function is unavailable.
|
||||
*/
|
||||
async getProgramsForChannel(
|
||||
epgChannelId: string | null | undefined
|
||||
): Promise<EpgItem[]> {
|
||||
const id = (epgChannelId ?? '').trim();
|
||||
if (!id) return [];
|
||||
|
||||
const fn = this.bridge?.getChannelPrograms;
|
||||
if (typeof fn !== 'function') return [];
|
||||
|
||||
try {
|
||||
const programs = await fn(id);
|
||||
return (programs ?? []).map((p) => mapEpgProgramToEpgItem(p, id));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to load XMLTV programs for ${id}`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentProgramsBatch(
|
||||
epgChannelIds: ReadonlyArray<string | null | undefined>
|
||||
): Promise<Record<string, EpgItem>> {
|
||||
const fn = this.bridge?.getCurrentProgramsBatch;
|
||||
if (typeof fn !== 'function') return {};
|
||||
|
||||
const ids = Array.from(
|
||||
new Set(
|
||||
epgChannelIds
|
||||
.map((id) => (id ?? '').trim())
|
||||
.filter((id): id is string => id.length > 0)
|
||||
)
|
||||
);
|
||||
if (ids.length === 0) return {};
|
||||
|
||||
try {
|
||||
const rows = await fn(ids);
|
||||
const out: Record<string, EpgItem> = {};
|
||||
for (const id of ids) {
|
||||
const row = rows?.[id];
|
||||
if (row) {
|
||||
out[id] = mapEpgProgramToEpgItem(row, id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Failed to load XMLTV current-programs batch',
|
||||
error
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve EPG for a single channel under the user's source-priority
|
||||
* setting. The Xtream provider's call is supplied by the caller via
|
||||
* `fetchProvider` so this service stays unaware of credentials. When
|
||||
* `preferUploaded` is true and XMLTV has data, the provider is not
|
||||
* called at all.
|
||||
*/
|
||||
async resolveCurrentEpg(args: {
|
||||
epgChannelId: string | null | undefined;
|
||||
preferUploaded: boolean;
|
||||
fetchProvider: () => Promise<EpgItem[]>;
|
||||
}): Promise<EpgItem[]> {
|
||||
const id = (args.epgChannelId ?? '').trim();
|
||||
|
||||
if (args.preferUploaded && id) {
|
||||
const xmltv = await this.getProgramsForChannel(id);
|
||||
if (xmltv.length > 0) return xmltv;
|
||||
return args.fetchProvider();
|
||||
}
|
||||
|
||||
const provider = await args.fetchProvider();
|
||||
if (provider.length > 0 || !id) return provider;
|
||||
return this.getProgramsForChannel(id);
|
||||
}
|
||||
}
|
||||
|
||||
function mapEpgProgramToEpgItem(
|
||||
program: EpgProgram,
|
||||
channelId: string
|
||||
): EpgItem {
|
||||
const startMs = program.startTimestamp
|
||||
? program.startTimestamp * 1000
|
||||
: Date.parse(program.start);
|
||||
const stopMs = program.stopTimestamp
|
||||
? program.stopTimestamp * 1000
|
||||
: Date.parse(program.stop);
|
||||
const startTimestamp = Number.isFinite(startMs)
|
||||
? Math.floor(startMs / 1000)
|
||||
: 0;
|
||||
const stopTimestamp = Number.isFinite(stopMs)
|
||||
? Math.floor(stopMs / 1000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: `${channelId}|${program.start}`,
|
||||
epg_id: '',
|
||||
title: program.title ?? '',
|
||||
lang: '',
|
||||
start: program.start,
|
||||
end: program.stop,
|
||||
stop: program.stop,
|
||||
description: program.desc ?? '',
|
||||
channel_id: channelId,
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import { DataService } from 'services';
|
||||
import { DataService, SettingsStore } from 'services';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { XtreamApiService } from '../../services/xtream-api.service';
|
||||
import { XtreamXmltvFallbackService } from '../../services/xtream-xmltv-fallback.service';
|
||||
import { withEpg } from './with-epg.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
@@ -21,14 +22,6 @@ const PLAYLIST = {
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
const TestEpgStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedItem: { xtream_id: 101 },
|
||||
}),
|
||||
withEpg()
|
||||
);
|
||||
|
||||
function buildProgram(
|
||||
title: string,
|
||||
startTimestamp: number,
|
||||
@@ -49,37 +42,77 @@ function buildProgram(
|
||||
};
|
||||
}
|
||||
|
||||
describe('withEpg', () => {
|
||||
let store: InstanceType<typeof TestEpgStore>;
|
||||
let xtreamApiService: {
|
||||
getFullEpg: jest.Mock<Promise<EpgItem[]>, unknown[]>;
|
||||
getShortEpg: jest.Mock<Promise<EpgItem[]>, unknown[]>;
|
||||
interface TestStoreSetup {
|
||||
selectedItem: { xtream_id: number; epg_channel_id?: string | null };
|
||||
preferUploaded?: boolean;
|
||||
}
|
||||
|
||||
function configureStore(setup: TestStoreSetup) {
|
||||
const TestEpgStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedItem: setup.selectedItem,
|
||||
}),
|
||||
withEpg()
|
||||
);
|
||||
|
||||
const xtreamApiService = {
|
||||
getFullEpg: jest.fn<Promise<EpgItem[]>, unknown[]>(),
|
||||
getShortEpg: jest.fn<Promise<EpgItem[]>, unknown[]>(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
xtreamApiService = {
|
||||
getFullEpg: jest.fn(),
|
||||
getShortEpg: jest.fn(),
|
||||
};
|
||||
const fallbackService = {
|
||||
getProgramsForChannel: jest.fn<Promise<EpgItem[]>, unknown[]>(),
|
||||
resolveCurrentEpg: jest.fn(
|
||||
async (args: {
|
||||
epgChannelId: string | null | undefined;
|
||||
preferUploaded: boolean;
|
||||
fetchProvider: () => Promise<EpgItem[]>;
|
||||
}): Promise<EpgItem[]> => {
|
||||
const id = (args.epgChannelId ?? '').trim();
|
||||
if (args.preferUploaded && id) {
|
||||
const xmltv =
|
||||
await fallbackService.getProgramsForChannel(id);
|
||||
if (xmltv.length > 0) return xmltv;
|
||||
return args.fetchProvider();
|
||||
}
|
||||
const provider = await args.fetchProvider();
|
||||
if (provider.length > 0 || !id) return provider;
|
||||
return fallbackService.getProgramsForChannel(id);
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestEpgStore,
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
{
|
||||
provide: XtreamApiService,
|
||||
useValue: xtreamApiService,
|
||||
},
|
||||
],
|
||||
});
|
||||
const settingsStore = {
|
||||
preferUploadedEpgOverXtream: jest.fn(
|
||||
() => setup.preferUploaded ?? false
|
||||
),
|
||||
};
|
||||
|
||||
store = TestBed.inject(TestEpgStore);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestEpgStore,
|
||||
{ provide: DataService, useValue: { isElectron: true } },
|
||||
{ provide: XtreamApiService, useValue: xtreamApiService },
|
||||
{
|
||||
provide: XtreamXmltvFallbackService,
|
||||
useValue: fallbackService,
|
||||
},
|
||||
{ provide: SettingsStore, useValue: settingsStore },
|
||||
],
|
||||
});
|
||||
|
||||
const store = TestBed.inject(TestEpgStore);
|
||||
return { store, xtreamApiService, fallbackService, settingsStore };
|
||||
}
|
||||
|
||||
describe('withEpg', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('loads the full electron epg and derives the current program from timestamps', async () => {
|
||||
const { store, xtreamApiService, fallbackService } = configureStore({
|
||||
selectedItem: { xtream_id: 101 },
|
||||
});
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const programs = [
|
||||
buildProgram('Past Show', now - 7200, now - 3600),
|
||||
@@ -97,13 +130,85 @@ describe('withEpg', () => {
|
||||
password: 'secret',
|
||||
},
|
||||
101,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
expect(fallbackService.getProgramsForChannel).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(programs);
|
||||
expect(store.epgItems()).toEqual(programs);
|
||||
expect(store.currentEpgItem()).toEqual(programs[1]);
|
||||
expect(store.isLoadingEpg()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to XMLTV when Xtream returns empty and epg_channel_id is set', async () => {
|
||||
const { store, xtreamApiService, fallbackService } = configureStore({
|
||||
selectedItem: { xtream_id: 101, epg_channel_id: 'rtl.de' },
|
||||
});
|
||||
xtreamApiService.getFullEpg.mockResolvedValue([]);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const xmltvPrograms = [
|
||||
buildProgram('XMLTV Show', now - 600, now + 600),
|
||||
];
|
||||
fallbackService.getProgramsForChannel.mockResolvedValue(xmltvPrograms);
|
||||
|
||||
const result = await store.loadEpg();
|
||||
|
||||
expect(fallbackService.getProgramsForChannel).toHaveBeenCalledWith(
|
||||
'rtl.de'
|
||||
);
|
||||
expect(result).toEqual(xmltvPrograms);
|
||||
expect(store.epgItems()).toEqual(xmltvPrograms);
|
||||
});
|
||||
|
||||
it('returns empty when Xtream is empty and epg_channel_id is missing', async () => {
|
||||
const { store, xtreamApiService, fallbackService } = configureStore({
|
||||
selectedItem: { xtream_id: 101, epg_channel_id: null },
|
||||
});
|
||||
xtreamApiService.getFullEpg.mockResolvedValue([]);
|
||||
|
||||
const result = await store.loadEpg();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(fallbackService.getProgramsForChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queries XMLTV first when preferUploadedEpgOverXtream is on', async () => {
|
||||
const { store, xtreamApiService, fallbackService } = configureStore({
|
||||
selectedItem: { xtream_id: 101, epg_channel_id: 'rtl.de' },
|
||||
preferUploaded: true,
|
||||
});
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const xmltvPrograms = [
|
||||
buildProgram('Curated Show', now - 600, now + 600),
|
||||
];
|
||||
fallbackService.getProgramsForChannel.mockResolvedValue(xmltvPrograms);
|
||||
|
||||
const result = await store.loadEpg();
|
||||
|
||||
expect(fallbackService.getProgramsForChannel).toHaveBeenCalledWith(
|
||||
'rtl.de'
|
||||
);
|
||||
expect(xtreamApiService.getFullEpg).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(xmltvPrograms);
|
||||
});
|
||||
|
||||
it('falls back to Xtream when preferUploadedEpgOverXtream is on but XMLTV is empty', async () => {
|
||||
const { store, xtreamApiService, fallbackService } = configureStore({
|
||||
selectedItem: { xtream_id: 101, epg_channel_id: 'rtl.de' },
|
||||
preferUploaded: true,
|
||||
});
|
||||
fallbackService.getProgramsForChannel.mockResolvedValue([]);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const apiPrograms = [
|
||||
buildProgram('Provider Show', now - 600, now + 600),
|
||||
];
|
||||
xtreamApiService.getFullEpg.mockResolvedValue(apiPrograms);
|
||||
|
||||
const result = await store.loadEpg();
|
||||
|
||||
expect(fallbackService.getProgramsForChannel).toHaveBeenCalledWith(
|
||||
'rtl.de'
|
||||
);
|
||||
expect(xtreamApiService.getFullEpg).toHaveBeenCalled();
|
||||
expect(result).toEqual(apiPrograms);
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { DataService } from 'services';
|
||||
import { DataService, SettingsStore } from 'services';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../../services/xtream-api.service';
|
||||
import { XtreamXmltvFallbackService } from '../../services/xtream-xmltv-fallback.service';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,7 @@ export function withEpg() {
|
||||
} | null;
|
||||
selectedItem?: () => {
|
||||
xtream_id?: number | null;
|
||||
epg_channel_id?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -85,6 +87,8 @@ export function withEpg() {
|
||||
withMethods((store) => {
|
||||
const apiService = inject(XtreamApiService);
|
||||
const dataService = inject(DataService);
|
||||
const fallbackService = inject(XtreamXmltvFallbackService);
|
||||
const settingsStore = inject(SettingsStore);
|
||||
|
||||
/**
|
||||
* Helper to get credentials from parent store
|
||||
@@ -104,9 +108,28 @@ export function withEpg() {
|
||||
};
|
||||
};
|
||||
|
||||
const preferUploaded = (): boolean =>
|
||||
settingsStore.preferUploadedEpgOverXtream?.() ?? false;
|
||||
|
||||
const fetchFullProvider = (
|
||||
credentials: XtreamCredentials,
|
||||
xtreamId: number
|
||||
): Promise<EpgItem[]> =>
|
||||
dataService.isElectron
|
||||
? apiService.getFullEpg(credentials, xtreamId, {
|
||||
suppressErrorLog: true,
|
||||
})
|
||||
: apiService.getShortEpg(credentials, xtreamId, 10, {
|
||||
suppressErrorLog: true,
|
||||
});
|
||||
|
||||
return {
|
||||
/**
|
||||
* Load EPG for the currently selected item
|
||||
* Load EPG for the currently selected item.
|
||||
* Falls back to local XMLTV (when configured in Settings → EPG)
|
||||
* if the Xtream provider returns no programs and the channel
|
||||
* has an `epg_channel_id`. The order is reversed when the user
|
||||
* sets `preferUploadedEpgOverXtream`.
|
||||
*/
|
||||
async loadEpg(): Promise<EpgItem[]> {
|
||||
const credentials = getCredentialsFromStore();
|
||||
@@ -115,7 +138,6 @@ export function withEpg() {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Access selected item from parent store (from withSelection)
|
||||
const storeAny = store as ParentSelectionStoreLike;
|
||||
const selectedItem = storeAny.selectedItem?.();
|
||||
|
||||
@@ -127,22 +149,16 @@ export function withEpg() {
|
||||
patchState(store, { epgItems: [], isLoadingEpg: true });
|
||||
|
||||
try {
|
||||
const epgItems = dataService.isElectron
|
||||
? await apiService.getFullEpg(
|
||||
credentials,
|
||||
selectedItem.xtream_id,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
)
|
||||
: await apiService.getShortEpg(
|
||||
credentials,
|
||||
selectedItem.xtream_id,
|
||||
10,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
const epgItems =
|
||||
await fallbackService.resolveCurrentEpg({
|
||||
epgChannelId: selectedItem.epg_channel_id,
|
||||
preferUploaded: preferUploaded(),
|
||||
fetchProvider: () =>
|
||||
fetchFullProvider(
|
||||
credentials,
|
||||
selectedItem.xtream_id!
|
||||
),
|
||||
});
|
||||
|
||||
patchState(store, {
|
||||
epgItems,
|
||||
@@ -160,24 +176,25 @@ export function withEpg() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load EPG for a specific channel (for preview)
|
||||
*/
|
||||
async loadChannelEpg(streamId: number): Promise<EpgItem[]> {
|
||||
async loadChannelEpg(
|
||||
streamId: number,
|
||||
epgChannelId?: string | null
|
||||
): Promise<EpgItem[]> {
|
||||
const credentials = getCredentialsFromStore();
|
||||
if (!credentials) {
|
||||
return [];
|
||||
}
|
||||
if (!credentials) return [];
|
||||
|
||||
try {
|
||||
return await apiService.getShortEpg(
|
||||
credentials,
|
||||
streamId,
|
||||
1,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
return await fallbackService.resolveCurrentEpg({
|
||||
epgChannelId,
|
||||
preferUploaded: preferUploaded(),
|
||||
fetchProvider: () =>
|
||||
apiService.getShortEpg(
|
||||
credentials,
|
||||
streamId,
|
||||
1,
|
||||
{ suppressErrorLog: true }
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading channel EPG', error);
|
||||
return [];
|
||||
|
||||
+12
-4
@@ -48,6 +48,7 @@ export interface XtreamChannelListItem {
|
||||
readonly title?: string;
|
||||
readonly type?: 'live' | 'movie' | 'series' | 'vod';
|
||||
readonly xtream_id: number;
|
||||
readonly epg_channel_id?: string | null;
|
||||
}
|
||||
|
||||
interface XtreamCategoryLike {
|
||||
@@ -200,7 +201,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
};
|
||||
|
||||
const visibleIds = new Set<number>(channels.map((ch) => ch.xtream_id));
|
||||
const uncachedIds: number[] = [];
|
||||
const uncachedEntries: { streamId: number; epgChannelId?: string | null }[] = [];
|
||||
|
||||
// Apply cached results immediately
|
||||
for (const channel of channels) {
|
||||
@@ -217,12 +218,19 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
if (!this.epgPrograms.has(channel.xtream_id)) {
|
||||
uncachedIds.push(channel.xtream_id);
|
||||
uncachedEntries.push({
|
||||
streamId: channel.xtream_id,
|
||||
epgChannelId: channel.epg_channel_id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (uncachedIds.length > 0) {
|
||||
this.epgQueueService.enqueue(uncachedIds, visibleIds, credentials);
|
||||
if (uncachedEntries.length > 0) {
|
||||
this.epgQueueService.enqueue(
|
||||
uncachedEntries,
|
||||
visibleIds,
|
||||
credentials
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
epgUrl: [],
|
||||
downloadFolder: '',
|
||||
coverSize: 'medium',
|
||||
preferUploadedEpgOverXtream: false,
|
||||
};
|
||||
|
||||
let embeddedMpvPrepareScheduled = false;
|
||||
@@ -140,6 +141,8 @@ export const SettingsStore = signalStore(
|
||||
epgUrl: store.epgUrl(),
|
||||
downloadFolder: store.downloadFolder!(),
|
||||
coverSize: store.coverSize!(),
|
||||
preferUploadedEpgOverXtream:
|
||||
store.preferUploadedEpgOverXtream!(),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -45,4 +45,12 @@ export interface Settings {
|
||||
downloadFolder?: string;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/**
|
||||
* When true, the locally-parsed XMLTV programs (loaded from `epgUrl`)
|
||||
* take precedence over the Xtream provider's EPG for live TV channels.
|
||||
* When false (default), the Xtream provider's EPG is preferred and
|
||||
* XMLTV is consulted only when the provider returns no programs.
|
||||
* Only meaningful for Xtream playlists in Electron.
|
||||
*/
|
||||
preferUploadedEpgOverXtream?: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user