mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06: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
@@ -89,6 +89,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="preferUploadedEpgOverXtream"
|
||||
data-test-id="toggle-prefer-uploaded-epg"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="settings-group__footer">
|
||||
<div class="inline-actions">
|
||||
<button mat-button type="button" (click)="addEpgSource.emit()">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@@ -14,6 +15,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
imports: [
|
||||
EpgSourceStatusComponent,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
|
||||
@@ -82,6 +82,7 @@ const DEFAULT_SETTINGS = {
|
||||
remoteControlPort: 8765,
|
||||
epgUrl: [],
|
||||
coverSize: 'medium',
|
||||
preferUploadedEpgOverXtream: false,
|
||||
};
|
||||
|
||||
class MockSettingsStore {
|
||||
|
||||
@@ -200,6 +200,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
],
|
||||
],
|
||||
coverSize: 'medium' as CoverSize,
|
||||
...(this.isDesktop
|
||||
? { preferUploadedEpgOverXtream: false }
|
||||
: {}),
|
||||
});
|
||||
|
||||
/** Form array with epg sources */
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "أضف رابطًا واحدًا أو أكثر كمصادر لـ EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "تفضيل مصادر EPG الخاصة بك لقوائم Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "بشكل افتراضي، تستخدم قوائم Xtream دليل البرامج من المزود الخاص بك، وتُستخدم مصادر EPG التي حمّلتها لملء القنوات المفقودة فقط. فعّل هذا الخيار لاستخدام EPG الذي حمّلته أولاً.",
|
||||
"DATA": "البيانات",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "زيد رابط ولا أكثر كمصادر EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "فضّل مصادر EPG ديالك ل Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "افتراضياً، قوائم Xtream كتستعمل دليل البرامج ديال المزود ديالك، ومصادر EPG ديالك كتعمر غير القنوات اللي ناقصة. فعّل هاد الخيار باش يتستعمل EPG ديالك الأول.",
|
||||
"DATA": "البيانات",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Дадайце адзін або некалькі URL у якасці крыніц EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Аддаваць перавагу вашым крыніцам EPG для плэйлістаў Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Па змаўчанні плэйлісты Xtream выкарыстоўваюць праграму перадач ад вашага правайдара, а вашы крыніцы EPG запаўняюць толькі адсутныя каналы. Уключыце, каб спачатку выкарыстоўваць загружаны EPG.",
|
||||
"DATA": "Дадзеныя",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Eine oder mehrere URLs als EPG-Quellen hinzufügen",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Eigene EPG-Quellen bei Xtream-Playlists bevorzugen",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Standardmäßig nutzen Xtream-Playlists die Programmdaten deines Anbieters, und dein hochgeladenes EPG füllt nur fehlende Kanäle. Aktiviere die Option, um stattdessen zuerst dein hochgeladenes EPG zu nutzen.",
|
||||
"DATA": "Daten",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Προσθέστε μία ή περισσότερες διευθύνσεις URL ως πηγές EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Προτίμηση των δικών σου πηγών EPG για λίστες Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Από προεπιλογή, οι λίστες Xtream χρησιμοποιούν τον οδηγό προγράμματος του παρόχου σου και οι δικές σου πηγές EPG συμπληρώνουν μόνο τα κανάλια που λείπουν. Ενεργοποίησέ το για να χρησιμοποιείται πρώτα το EPG που ανέβασες.",
|
||||
"DATA": "Δεδομένα",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Add one or more URLs as EPG sources",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Prefer your EPG sources for Xtream playlists",
|
||||
"PREFER_UPLOADED_EPG_HINT": "By default, Xtream playlists use the program guide from your provider, and your uploaded EPG only fills missing channels. Turn this on to use your uploaded EPG first instead.",
|
||||
"DATA": "Data",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Agrega una o varias URL como fuentes EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Preferir tus fuentes EPG para listas Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "De forma predeterminada, las listas Xtream usan la guía de programas de tu proveedor, y tus fuentes EPG solo rellenan los canales que faltan. Activa esta opción para usar antes tu EPG.",
|
||||
"DATA": "Datos",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Ajoutez une ou plusieurs URL comme sources EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Préférer vos sources EPG pour les playlists Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Par défaut, les playlists Xtream utilisent le guide des programmes de votre fournisseur, et vos sources EPG ne comblent que les chaînes manquantes. Activez cette option pour utiliser d'abord votre EPG.",
|
||||
"DATA": "Données",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Aggiungi uno o piu URL come sorgenti EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Preferisci le tue sorgenti EPG per le playlist Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Per impostazione predefinita, le playlist Xtream usano la guida programmi del tuo provider e le tue sorgenti EPG riempiono solo i canali mancanti. Attiva questa opzione per usare prima il tuo EPG.",
|
||||
"DATA": "Dati",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "EPGソースとして1つ以上のURLを追加",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Xtream プレイリストでアップロードした EPG を優先する",
|
||||
"PREFER_UPLOADED_EPG_HINT": "標準では Xtream プレイリストはプロバイダの番組表を使用し、アップロードした EPG は不足しているチャンネルのみを補完します。これを有効にすると、アップロードした EPG を優先して使用します。",
|
||||
"DATA": "データ",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "하나 이상의 URL을 EPG 소스로 추가합니다",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Xtream 재생 목록에서 업로드한 EPG 우선 사용",
|
||||
"PREFER_UPLOADED_EPG_HINT": "기본적으로 Xtream 재생 목록은 공급자의 프로그램 가이드를 사용하며, 업로드한 EPG는 누락된 채널만 채웁니다. 이 옵션을 켜면 업로드한 EPG를 먼저 사용합니다.",
|
||||
"DATA": "데이터",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Voeg een of meer URL's toe als EPG-bron",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Eigen EPG-bronnen verkiezen voor Xtream-afspeellijsten",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Standaard gebruiken Xtream-afspeellijsten de programmagids van je provider, en je EPG-bronnen vullen alleen ontbrekende kanalen aan. Schakel dit in om eerst je geüploade EPG te gebruiken.",
|
||||
"DATA": "Gegevens",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Dodaj jeden lub więcej adresów URL jako źródła EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Preferuj własne źródła EPG dla playlist Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Domyślnie playlisty Xtream korzystają z przewodnika po programie od dostawcy, a Twoje źródła EPG uzupełniają tylko brakujące kanały. Włącz, aby najpierw używać przesłanego EPG.",
|
||||
"DATA": "Dane",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Adicione uma ou mais URLs como fontes de EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Preferir as suas fontes EPG para listas Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Por padrão, as listas Xtream usam a guia de programação do seu provedor, e as suas fontes EPG só preenchem os canais em falta. Ative esta opção para usar primeiro o seu EPG.",
|
||||
"DATA": "Dados",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "Добавьте один или несколько URL-адресов источников EPG",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Предпочитать ваши источники EPG для плейлистов Xtream",
|
||||
"PREFER_UPLOADED_EPG_HINT": "По умолчанию плейлисты Xtream используют программу передач от вашего провайдера, а ваши источники EPG заполняют только недостающие каналы. Включите, чтобы сначала использовать ваш загруженный EPG.",
|
||||
"DATA": "Данные",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "EPG kaynağı olarak bir veya daha fazla URL ekleyin",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "Xtream oynatma listeleri için yüklediğin EPG'yi tercih et",
|
||||
"PREFER_UPLOADED_EPG_HINT": "Varsayılan olarak Xtream oynatma listeleri sağlayıcının yayın akışını kullanır ve yüklediğin EPG yalnızca eksik kanalları doldurur. Bunu açarak yüklediğin EPG'yi önce kullanabilirsin.",
|
||||
"DATA": "Veri",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "添加一个或多个 URL 作为 EPG 源",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "为 Xtream 播放列表优先使用上传的 EPG",
|
||||
"PREFER_UPLOADED_EPG_HINT": "默认情况下,Xtream 播放列表使用提供商的节目指南,您上传的 EPG 仅用于补充缺失的频道。启用此选项可优先使用您上传的 EPG。",
|
||||
"DATA": "数据",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"EPG_SOURCES_DESCRIPTION": "新增一個或多個網址作為 EPG 來源",
|
||||
"EPG_EMPTY_TITLE": "No EPG sources yet",
|
||||
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
|
||||
"PREFER_UPLOADED_EPG_TITLE": "為 Xtream 播放清單優先使用上傳的 EPG",
|
||||
"PREFER_UPLOADED_EPG_HINT": "預設情況下,Xtream 播放清單會使用供應商的節目表,您上傳的 EPG 僅用於補足缺少的頻道。開啟此選項以優先使用您上傳的 EPG。",
|
||||
"DATA": "資料",
|
||||
"BACKUP": "Backup",
|
||||
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
|
||||
|
||||
@@ -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