diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts
index a80a56979..1ad524812 100644
--- a/apps/web/src/app/app.component.ts
+++ b/apps/web/src/app/app.component.ts
@@ -1,4 +1,11 @@
-import { Component, effect, HostBinding, inject, OnInit } from '@angular/core';
+import {
+ Component,
+ effect,
+ HostBinding,
+ inject,
+ OnInit,
+ signal,
+} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
@@ -34,6 +41,7 @@ import { SettingsService } from './services/settings.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
+import { AppStartupStatusComponent } from './app-startup-status.component';
const debugAppComponent = createDevLogger('AppComponent');
@@ -41,6 +49,7 @@ const debugAppComponent = createDevLogger('AppComponent');
selector: 'app-root',
templateUrl: './app.component.html',
imports: [
+ AppStartupStatusComponent,
AppUpdateNotificationPanelComponent,
EpgProgressPanelComponent,
RouterOutlet,
@@ -48,6 +57,7 @@ const debugAppComponent = createDevLogger('AppComponent');
],
})
export class AppComponent implements OnInit {
+ readonly routeReady = signal(false);
@HostBinding('class.macos-platform') get isMacOS() {
return this.runtime.isMacOS;
}
diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 5330a9ad2..d0a17c7b1 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب",
"NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز."
}
+ },
+ "STARTUP": {
+ "PREPARING": "جارٍ تجهيز المكتبة",
+ "PREPARING_DETAIL": "جارٍ تحميل المصادر والتحقق من دليل البرامج. قد تستغرق المكتبات الكبيرة وقتًا أطول.",
+ "FAILED": "تعذر تحميل المصادر",
+ "FAILED_DETAIL": "أعد محاولة قراءة المصادر المحفوظة. لم تُحذف بياناتك."
}
}
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 09c116d35..74cad55be 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب",
"NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز."
}
+ },
+ "STARTUP": {
+ "PREPARING": "كنوجدو المكتبة ديالك",
+ "PREPARING_DETAIL": "كنحمّلو المصادر وكنراجعو دليل البرامج. المكتبات الكبيرة تقدر تاخد وقت أكثر.",
+ "FAILED": "ما قدرناش نحمّلو المصادر",
+ "FAILED_DETAIL": "عاود جرّب تقرا المصادر المحفوظة. البيانات ديالك ما تمسحوش."
}
}
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index af5c0cf2f..9c876ff4f 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт",
"NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Падрыхтоўка бібліятэкі",
+ "PREPARING_DETAIL": "Загружаем крыніцы і правяраем тэлепраграму. Вялікая бібліятэка можа патрабаваць больш часу.",
+ "FAILED": "Не ўдалося загрузіць крыніцы",
+ "FAILED_DETAIL": "Паўтарыце чытанне захаваных крыніц. Вашы даныя не выдалены."
}
}
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index 0c4e20335..685e26ff4 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Keine Kontodaten",
"NO_ACCOUNT_DATA_DESCRIPTION": "Dieses Portal stellt für dieses Gerät keine Kontoinformationen bereit."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Bibliothek wird vorbereitet",
+ "PREPARING_DETAIL": "Quellen werden geladen und der Programmführer geprüft. Große Bibliotheken können etwas länger dauern.",
+ "FAILED": "Quellen konnten nicht geladen werden",
+ "FAILED_DETAIL": "Versuche erneut, die gespeicherten Quellen zu lesen. Deine Daten wurden nicht gelöscht."
}
}
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index cc2999dba..dc147d7c5 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού",
"NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Προετοιμασία βιβλιοθήκης",
+ "PREPARING_DETAIL": "Φόρτωση πηγών και έλεγχος του οδηγού προγράμματος. Οι μεγάλες βιβλιοθήκες μπορεί να χρειαστούν περισσότερο χρόνο.",
+ "FAILED": "Δεν ήταν δυνατή η φόρτωση των πηγών",
+ "FAILED_DETAIL": "Δοκιμάστε ξανά την ανάγνωση των αποθηκευμένων πηγών. Τα δεδομένα σας δεν διαγράφηκαν."
}
}
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index 229a9c985..21f146718 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "No account details",
"NO_ACCOUNT_DATA_DESCRIPTION": "This portal does not publish account information for this device."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Preparing your library",
+ "PREPARING_DETAIL": "Loading your sources and checking the programme guide. Large libraries may take a little longer.",
+ "FAILED": "Your sources could not be loaded",
+ "FAILED_DETAIL": "Retry to read your saved sources again. Your data has not been deleted."
}
}
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index 95aab4e56..6e533623e 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Sin datos de la cuenta",
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal no publica información de la cuenta para este dispositivo."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Preparando tu biblioteca",
+ "PREPARING_DETAIL": "Cargando fuentes y comprobando la guía de programación. Las bibliotecas grandes pueden tardar un poco más.",
+ "FAILED": "No se pudieron cargar tus fuentes",
+ "FAILED_DETAIL": "Vuelve a intentar leer las fuentes guardadas. Tus datos no se han eliminado."
}
}
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index d82326c83..e1b87d058 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Aucune donnée de compte",
"NO_ACCOUNT_DATA_DESCRIPTION": "Ce portail ne publie pas d’informations de compte pour cet appareil."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Préparation de la bibliothèque",
+ "PREPARING_DETAIL": "Chargement des sources et vérification du guide TV. Les grandes bibliothèques peuvent prendre plus de temps.",
+ "FAILED": "Impossible de charger vos sources",
+ "FAILED_DETAIL": "Réessayez de lire les sources enregistrées. Vos données n’ont pas été supprimées."
}
}
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index 9752ab0a8..d5459bda1 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Nincsenek fiókadatok",
"NO_ACCOUNT_DATA_DESCRIPTION": "Ez a portál nem tesz közzé fiókadatokat ehhez az eszközhöz."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Könyvtár előkészítése",
+ "PREPARING_DETAIL": "Források betöltése és a műsorújság ellenőrzése. Nagy könyvtárak esetén ez tovább tarthat.",
+ "FAILED": "Nem sikerült betölteni a forrásokat",
+ "FAILED_DETAIL": "Próbáld újra beolvasni a mentett forrásokat. Az adataid nem lettek törölve."
}
}
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index e0d74255e..727656c69 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Nessun dato account",
"NO_ACCOUNT_DATA_DESCRIPTION": "Questo portale non pubblica informazioni sull’account per questo dispositivo."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Preparazione della libreria",
+ "PREPARING_DETAIL": "Caricamento delle sorgenti e verifica della guida TV. Le librerie grandi possono richiedere più tempo.",
+ "FAILED": "Impossibile caricare le sorgenti",
+ "FAILED_DETAIL": "Riprova a leggere le sorgenti salvate. I tuoi dati non sono stati eliminati."
}
}
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index c5288e5c2..e95bc5295 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "アカウント情報なし",
"NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。"
}
+ },
+ "STARTUP": {
+ "PREPARING": "ライブラリを準備しています",
+ "PREPARING_DETAIL": "ソースを読み込み、番組表を確認しています。大きなライブラリでは時間がかかる場合があります。",
+ "FAILED": "ソースを読み込めませんでした",
+ "FAILED_DETAIL": "保存したソースの読み込みを再試行してください。データは削除されていません。"
}
}
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 6f22cadd2..b5c6e01d8 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "계정 정보 없음",
"NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다."
}
+ },
+ "STARTUP": {
+ "PREPARING": "라이브러리 준비 중",
+ "PREPARING_DETAIL": "소스를 불러오고 편성표를 확인하고 있습니다. 라이브러리가 크면 시간이 더 걸릴 수 있습니다.",
+ "FAILED": "소스를 불러오지 못했습니다",
+ "FAILED_DETAIL": "저장된 소스를 다시 읽어 보세요. 데이터는 삭제되지 않았습니다."
}
}
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index fc63d3a13..f8a0c5bbd 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Geen accountgegevens",
"NO_ACCOUNT_DATA_DESCRIPTION": "Dit portaal publiceert geen accountinformatie voor dit apparaat."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Bibliotheek voorbereiden",
+ "PREPARING_DETAIL": "Bronnen laden en de programmagids controleren. Grote bibliotheken kunnen wat meer tijd kosten.",
+ "FAILED": "Je bronnen konden niet worden geladen",
+ "FAILED_DETAIL": "Probeer je opgeslagen bronnen opnieuw te lezen. Je gegevens zijn niet verwijderd."
}
}
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index c8c9261bf..4bc3c4d43 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Brak danych konta",
"NO_ACCOUNT_DATA_DESCRIPTION": "Ten portal nie udostępnia informacji o koncie dla tego urządzenia."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Przygotowywanie biblioteki",
+ "PREPARING_DETAIL": "Wczytywanie źródeł i sprawdzanie programu TV. Duże biblioteki mogą wymagać więcej czasu.",
+ "FAILED": "Nie udało się wczytać źródeł",
+ "FAILED_DETAIL": "Spróbuj ponownie odczytać zapisane źródła. Twoje dane nie zostały usunięte."
}
}
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index 5ee835fa0..040d78b27 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Sem dados da conta",
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal não publica informações da conta para este dispositivo."
}
+ },
+ "STARTUP": {
+ "PREPARING": "A preparar a biblioteca",
+ "PREPARING_DETAIL": "A carregar fontes e verificar o guia de programação. Bibliotecas grandes podem demorar mais.",
+ "FAILED": "Não foi possível carregar as fontes",
+ "FAILED_DETAIL": "Tente ler novamente as fontes guardadas. Os seus dados não foram eliminados."
}
}
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index 5197fecaa..385689112 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте",
"NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Подготовка библиотеки",
+ "PREPARING_DETAIL": "Загружаем источники и проверяем телепрограмму. Большая библиотека может потребовать больше времени.",
+ "FAILED": "Не удалось загрузить источники",
+ "FAILED_DETAIL": "Повторите чтение сохранённых источников. Ваши данные не удалены."
}
}
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index 46e1ed2ed..1a4af4be7 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "Hesap verisi yok",
"NO_ACCOUNT_DATA_DESCRIPTION": "Bu portal, bu cihaz için hesap bilgisi yayınlamıyor."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Kitaplığınız hazırlanıyor",
+ "PREPARING_DETAIL": "Kaynaklar yükleniyor ve program rehberi kontrol ediliyor. Büyük kitaplıklar biraz daha uzun sürebilir.",
+ "FAILED": "Kaynaklarınız yüklenemedi",
+ "FAILED_DETAIL": "Kayıtlı kaynaklarınızı okumayı yeniden deneyin. Verileriniz silinmedi."
}
}
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index b161b6d6e..5504fc7b7 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "无账户数据",
"NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。"
}
+ },
+ "STARTUP": {
+ "PREPARING": "正在准备媒体库",
+ "PREPARING_DETAIL": "正在加载来源并检查节目指南。较大的媒体库可能需要更长时间。",
+ "FAILED": "无法加载来源",
+ "FAILED_DETAIL": "请重试读取已保存的来源。您的数据未被删除。"
}
}
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index 8e36dbb66..f029e5448 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -1730,5 +1730,11 @@
"NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料",
"NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。"
}
+ },
+ "STARTUP": {
+ "PREPARING": "正在準備媒體庫",
+ "PREPARING_DETAIL": "正在載入來源並檢查節目指南。較大的媒體庫可能需要更長時間。",
+ "FAILED": "無法載入來源",
+ "FAILED_DETAIL": "請重試讀取已儲存的來源。您的資料未被刪除。"
}
}
diff --git a/apps/website/README.md b/apps/website/README.md
index 95701aa20..1470427fd 100644
--- a/apps/website/README.md
+++ b/apps/website/README.md
@@ -82,7 +82,8 @@ skip shows up in your output.
Evergreen how-to posts live in the blog collection next to release notes
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`,
`m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx`,
-`alternative-sources-guide.mdx` and `remote-control-guide.mdx` in
+`alternative-sources-guide.mdx`, `remote-control-guide.mdx` and
+`epg-wrong-program-fix.mdx` in
`apps/website/src/content/blog/`). Three conventions set them apart:
- **`ContentDisclaimer`.** Every guide opens with
@@ -119,6 +120,10 @@ Evergreen how-to posts live in the blog collection next to release notes
`tools/release/capture-release-screenshots.ts`). Its setup selects a live
channel and saves the remote-control setting, so the app's own server answers
on port 8765 for the duration of the run.
+ The EPG-mapping shots import the mock's XMLTV guide (`/demo/guide.xml`,
+ channel ids that match no playlist `tvg-id` on purpose) into the isolated
+ database through the settings, then right-click a channel of the M3U
+ fixture and search the dialog.
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
each guide for the FAQPage schema, a link to the download hub and the presence
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png
new file mode 100644
index 000000000..eb09ddd5b
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-light.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-light.png
new file mode 100644
index 000000000..d3dbf569e
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-dark.png
new file mode 100644
index 000000000..b20164c89
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-light.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-light.png
new file mode 100644
index 000000000..6e38c5661
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-map-menu-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-offset-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-offset-dark.png
new file mode 100644
index 000000000..2ea6d330f
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-offset-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-offset-light.png b/apps/website/public/blog/guides/screenshots/guide-epg-offset-light.png
new file mode 100644
index 000000000..4af321e15
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-epg-offset-light.png differ
diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt
index 350b5a7a3..5ef4012a4 100644
--- a/apps/website/public/llms.txt
+++ b/apps/website/public/llms.txt
@@ -21,6 +21,7 @@
- Guide, offline viewing with the download manager (desktop): https://4gray.github.io/iptvnator/blog/offline-downloads-guide/
- Guide, alternative sources for a movie across your own playlists (desktop): https://4gray.github.io/iptvnator/blog/alternative-sources-guide/
- Guide, phone remote control setup (desktop): https://4gray.github.io/iptvnator/blog/remote-control-guide/
+- Troubleshooting, EPG shows the wrong program or nothing (channel mapping, time offset): https://4gray.github.io/iptvnator/blog/epg-wrong-program-fix/
- Features overview: https://4gray.github.io/iptvnator/features/
- Feature, M3U playlist player: https://4gray.github.io/iptvnator/features/m3u-player/
- Feature, Xtream Codes player: https://4gray.github.io/iptvnator/features/xtream-codes-player/
diff --git a/apps/website/src/content/blog/epg-wrong-program-fix.mdx b/apps/website/src/content/blog/epg-wrong-program-fix.mdx
new file mode 100644
index 000000000..dd9efb807
--- /dev/null
+++ b/apps/website/src/content/blog/epg-wrong-program-fix.mdx
@@ -0,0 +1,145 @@
+---
+title: EPG Shows the Wrong Program or Nothing? Fix Channel Mapping and Time Offset in IPTVnator
+description: When the program guide is empty for a channel, shows another channel's schedule, or is off by a few hours, the fix is one of three things. How IPTVnator matches guide data to channels, how to map a channel by hand, and how to correct the time offset without touching the guide.
+pubDate: 2026-09-06
+author: 4gray
+heroImage: /iptvnator/blog/guides/screenshots/guide-epg-map-dialog-dark.png
+tags:
+ - troubleshooting
+ - epg
+draft: false
+faq:
+- q: Why does one channel have no program information while the others do?
+ a: The guide has no entry the app can attach to that channel. IPTVnator matches an XMLTV channel by the playlist's tvg-id, then by tvg-name, then by the channel name; when none of them matches an id or display name in the guide, the row stays empty. Map the channel by hand with a right-click and "Map EPG channel".
+- q: Where is the mapping stored, and does it survive a playlist refresh?
+ a: On your device, in the local database of the desktop app, keyed by the channel, not by the playlist file. Refreshing the playlist or the guide keeps it. "Remove mapping" in the same dialog deletes it.
+- q: Every program is shown a few hours early or late. Is the guide broken?
+ a: Usually the guide labels its times with a timezone that does not match the broadcast, or your provider's clock is off. Settings › EPG › EPG time offset shifts every displayed time by a number of minutes without refreshing the guide. Use a negative value when programs appear too late and a positive value when they appear too early.
+- q: Does the offset change catch-up or recordings?
+ a: No. It only changes what is displayed. Catch-up requests, recording snapshots and the stored guide keep the provider's own times, so the offset can never break playback.
+- q: Can I map channels from an Xtream or Stalker portal?
+ a: Yes, in the desktop app. Portals bring their own guide, but you can attach an uploaded XMLTV channel to any live channel by right-clicking it in the channel list. For Xtream sources, the setting "Prefer your EPG sources for Xtream playlists" makes the uploaded guide win over the provider's whenever both exist.
+- q: Does the browser version support mapping and the offset?
+ a: No. Both belong to the desktop app, which is where XMLTV guides are downloaded and stored. The browser version shows the guide a portal provides and nothing else.
+- q: The mapping dialog finds no channels at all. Why?
+ a: No guide is loaded yet, or the guide has not finished importing. Add the XMLTV source in Settings › EPG, fetch it, and wait for the import to finish before you search. The search needs at least two characters.
+---
+
+import Alert from '../../components/blog/Alert.astro';
+import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
+import PostButton from '../../components/blog/PostButton.astro';
+import StepRail from '../../components/blog/StepRail.astro';
+import LinkCards from '../../components/blog/LinkCards.astro';
+
+The guide is loaded, most channels show what is on, and then one row says *No program
+information available*, another shows the schedule of a different station, and the evening
+news appears at two in the afternoon. All three are common, all three have one specific
+cause, and none of them needs a new playlist.
+
+This post explains how IPTVnator decides which guide entry belongs to which channel, how to
+map a channel by hand when that decision goes wrong, and how to correct times that are off by
+a fixed amount. It applies to the desktop app on Windows, macOS and Linux, where XMLTV guides
+are downloaded and stored.
+
+
+
+## Which of the three problems you have
+
+| What you see | Cause | Fix |
+| --- | --- | --- |
+| No program information for one or a few channels | No guide entry could be attached to the channel | Map the channel by hand |
+| A channel shows another channel's schedule | The playlist's id points at the wrong guide entry | Map the channel by hand |
+| Every program is early or late by the same amount | The guide's times carry the wrong timezone | Set the EPG time offset |
+| Everything is empty | The guide did not load at all | Check the source in Settings › EPG |
+
+The last case is covered in the [M3U and EPG setup guide](/iptvnator/blog/m3u-playlist-epg-setup-guide/);
+this post is about the first three.
+
+## How a channel finds its guide entry
+
+An XMLTV guide lists channels with an id and a display name, and programs that refer to the
+id. When the desktop app looks for the guide of a playlist channel, it tries, in this order:
+
+1. the playlist's `tvg-id` against the guide's channel id,
+2. the playlist's `tvg-name` against the guide's display names,
+3. the channel name itself against the display names.
+
+When the playlist carries ids from a different guide than the one you loaded, step one fails,
+and steps two and three only succeed when the names happen to be identical. "Aurora News HD"
+in the playlist and "Aurora News" in the guide are different names to the matcher. That is the
+empty row. A playlist whose ids are simply wrong gives the second symptom: the id exists in the
+guide, but belongs to another station.
+
+## Map a channel by hand
+
+
+
+
+
+
+
+A mapping belongs to the channel, not to the playlist file. Refreshing the playlist, refreshing
+the guide or restarting the app keeps it. Open the same dialog to change it; **Remove mapping**
+returns the channel to automatic matching.
+
+
+ Xtream and Stalker sources receive program data from the provider and do not need an XMLTV
+ file. Mapping is still available for them: it attaches an uploaded guide entry to a portal
+ channel whose provider data is missing or wrong. For Xtream, the setting "Prefer your EPG
+ sources for Xtream playlists" in Settings › EPG decides which guide wins when both exist.
+
+
+## Correct times that are off by hours
+
+If every program is shifted by the same amount, the guide is fine and only its clock is not.
+Many providers label their XMLTV times with the wrong timezone, and a few servers run on a
+clock that is simply off. Open **Settings › EPG › EPG time offset** and enter the correction
+in minutes, from −720 to 720. The change applies immediately to every guide view, the channel
+list, the dashboard and the timeline, for every source.
+
+
+
+Which sign to use:
+
+- Programs appear **too early** (the news that starts at 20:00 is shown at 18:00): the guide is
+ behind; enter a **positive** value, here `120`.
+- Programs appear **too late**: enter a **negative** value.
+
+The offset changes only what you see. The guide stays stored as delivered, catch-up requests
+and recording snapshots keep the provider's own times, and a refresh of the guide does not
+reset the setting.
+
+
+ A guide that carries the wrong timezone is usually wrong by a different amount after a
+ daylight-saving change. If programs drift by an hour in spring or autumn, adjust the offset
+ rather than the source.
+
+
+## When it is the source after all
+
+Mapping and the offset fix a guide that is present but attached or timed wrongly. They cannot
+fix a guide that does not carry the station at all. If the search in the mapping dialog finds
+nothing sensible, the loaded XMLTV covers a different region or provider than your playlist.
+Add a guide that matches the channels, or ask the provider for the URL of theirs; playlists
+often carry it in the `url-tvg` attribute of the first line, and the desktop app offers to
+import those automatically.
+
+## Related
+
+
+
+
diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md
index 3f53466c3..3f40c233e 100644
--- a/apps/xtream-mock-server/README.md
+++ b/apps/xtream-mock-server/README.md
@@ -233,6 +233,7 @@ application code.
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
- **Unusable timezone name**: `tzoffset:tzoffset` serves the same EPG fixture behind a panel whose `server_info.timezone` is the ICU-unknown spelling `UTC+3`, while its `time_now` / `timestamp_now` clock pair reveals a +03:00 offset — the catch-up URL must be derived from the clock pair (issue #1562)
- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing`
+- **Demo XMLTV guide**: `GET /demo/guide.xml` returns an XMLTV file for the marketing live channels (ids such as `aurora-news.fictional`, eight half-hour programmes around now, logos from the local asset route), so the desktop app can import an EPG source hermetically; guide screenshots use it for the manual channel-mapping flow
- **Alternative-source fixture**: `marketing2:marketing2` returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
- **Local download media**: `marketing:marketing` also serves `/movie/...`, `/series/...` and `/live/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
- **Performance fixture**: `performance:performance` returns exactly 100,000
diff --git a/apps/xtream-mock-server/src/app/demo-xmltv.ts b/apps/xtream-mock-server/src/app/demo-xmltv.ts
new file mode 100644
index 000000000..85d0bf9ff
--- /dev/null
+++ b/apps/xtream-mock-server/src/app/demo-xmltv.ts
@@ -0,0 +1,72 @@
+import { MARKETING_LIVE_CHANNELS } from '@iptvnator/shared/marketing-fixtures';
+
+/**
+ * `/demo/guide.xml`: an XMLTV guide for the marketing live channels, so the
+ * desktop app can import an EPG source that never leaves the machine. The
+ * channel ids deliberately differ from any playlist's `tvg-id`, which is what
+ * makes the manual "Map EPG channel" flow worth showing in guide screenshots.
+ */
+export function renderDemoXmltv(origin: string, now = new Date()): string {
+ const slotMs = 30 * 60 * 1000;
+ const roundedNow = now.getTime() - (now.getTime() % slotMs);
+ const channels = MARKETING_LIVE_CHANNELS.map((channel) => ({
+ id: `${slugify(channel.name)}.fictional`,
+ name: channel.name,
+ icon: `${origin}/assets/marketing/logo/${slugify(channel.name)}.svg?size=256x256`,
+ titles: channel.epgTitles,
+ }));
+ const lines = [
+ '',
+ '',
+ '',
+ ];
+
+ for (const channel of channels) {
+ lines.push(
+ ` `,
+ ` ${escapeXml(channel.name)}`,
+ ` `,
+ ' '
+ );
+ }
+
+ for (const channel of channels) {
+ for (let index = 0; index < 8; index += 1) {
+ const title = channel.titles[index % channel.titles.length];
+ const start = roundedNow + (index - 2) * slotMs;
+ lines.push(
+ ` `,
+ ` ${escapeXml(title)}`,
+ ` ${escapeXml(`${title} on ${channel.name}, part of the fictional IPTVnator demo schedule.`)}`,
+ ' '
+ );
+ }
+ }
+
+ lines.push('', '');
+ return lines.join('\n');
+}
+
+function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
+function formatXmltvDate(timestamp: number): string {
+ const date = new Date(timestamp);
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return (
+ `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
+ `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())} +0000`
+ );
+}
+
+function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts
index 9ee3a6201..d17a6225c 100644
--- a/apps/xtream-mock-server/src/app/server.spec.ts
+++ b/apps/xtream-mock-server/src/app/server.spec.ts
@@ -147,6 +147,26 @@ describe('Xtream mock server factory', () => {
}
});
+ it('serves an XMLTV guide for the marketing live channels at /demo/guide.xml', async () => {
+ const running = await startLoopbackServer(
+ createXtreamMockApp({ host: '127.0.0.1', port: 0 })
+ );
+ try {
+ const response = await fetch(`${running.origin}/demo/guide.xml`);
+ const xml = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toContain('xml');
+ expect(xml).toContain('Aurora News');
+ expect(xml).toContain('channel="aurora-news.fictional"');
+ expect(xml).toContain(`${running.origin}/assets/marketing/logo/aurora-news.svg`);
+ expect(xml).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost)/);
+ } finally {
+ await running.close();
+ }
+ });
+
it('serves the marketing catalog twice so the second credential pair is an alternative source', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts
index 1bdee5a51..753e050eb 100644
--- a/apps/xtream-mock-server/src/app/server.ts
+++ b/apps/xtream-mock-server/src/app/server.ts
@@ -16,6 +16,7 @@ import {
} from './performance-control.js';
import { dispatchAction } from './routes/dispatch.js';
import { getScenario, type ScenarioConfig } from './scenarios.js';
+import { renderDemoXmltv } from './demo-xmltv.js';
import {
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS,
LOCAL_MEDIA_LIVE_STREAM_OPTIONS,
@@ -107,6 +108,7 @@ export function createXtreamMockApp(
.send(M3U_FIXTURE);
});
installMarketingAssetRoute(app);
+ installDemoGuideRoute(app);
app.get('/player_api.php', (request, response, next) => {
dispatchWithControl(controller, request, response, next, 'direct', () =>
@@ -214,6 +216,17 @@ function dispatchProxyAction(request: Request, response: Response): void {
response.json({ payload, action });
}
+/** XMLTV for the marketing live channels; see `demo-xmltv.ts`. */
+function installDemoGuideRoute(app: express.Express): void {
+ app.get('/demo/guide.xml', (request, response) => {
+ const origin = `${request.protocol}://${request.get('host') ?? `localhost:${DEFAULT_PORT}`}`;
+ response
+ .type('application/xml')
+ .set('Cache-Control', 'no-store')
+ .send(renderDemoXmltv(origin));
+ });
+}
+
function installMarketingAssetRoute(app: express.Express): void {
app.get('/assets/marketing/:kind/:slug', (request, response) => {
const kind = request.params['kind'] as
diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md
index 4f2671d69..6fb2d449a 100644
--- a/docs/architecture/m3u-playlist-module.md
+++ b/docs/architecture/m3u-playlist-module.md
@@ -76,12 +76,42 @@ migrations. In particular, `idx_content_epg_channel` must follow the addition of
`content.epg_channel_id`; creating it in the initial CREATE TABLE pass aborts
initialization on the v0.19 schema before the playlist migration can run.
+Initial workspace routing waits for settings and XMLTV source reconciliation.
+Removing a large obsolete EPG cache can therefore delay the first view even
+after legacy recovery is declined. `AppStartupStatusComponent` shows preparation
+until both the initial routed component and source inventory are ready; routed
+content stays hidden from pointer and keyboard interaction during that interval.
+There is no estimated percentage or timeout that bypasses reconciliation.
+
+Source inventory reads recreate the storage request for one automatic retry
+after 300 ms. Exhausted retries emit `Load Playlists Failure` without raw error
+details and leave `allPlaylistsLoaded` false. The startup error offers Retry,
+which dispatches a fresh load without restarting or changing saved sources.
+Before publishing a successful inventory, the effect waits for settings loading
+to register initial cleanup, then waits for any active EPG reconciliation and retries the last failed synchronization using its committed,
+normalized global URLs. A settings-read failure never supplies defaults to this
+retry; a newer successful synchronization clears the failure instead of replaying
+an older source set. Repeated cleanup failure leaves Retry available.
+Every inventory load clears readiness, including reloads after backup import,
+so a later read failure also exposes Retry. A successfully loaded empty inventory
+is distinct from a failed read. The preparation/error surface remains a native
+window drag region, with Retry excluded so it stays clickable. Its static,
+monochrome SVG watermark inherits the theme heading color, fades toward the
+content, and is clipped, pointer-transparent and hidden from assistive technology.
+
Validation: `electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`
seeds the exact v0.19 IndexedDB schema and verbatim SQL CREATE statements with
61 Stalker sources, three Xtream sources, M3U, and linked cached user data.
It exercises source-list UI, alternate last-used sources, retained settings,
recovery consent, existing data, write failure, restart, and deletion after
-migration. Local macOS Electron verification does not substitute for installed
+migration. The refusal case holds a mocked recovery dialog pending until startup
+reaches it, then checks that Sources renders the current source in the same
+launch. Native operating-system dialogs still require separate manual verification.
+The same suite holds EPG reconciliation pending and injects failed metadata reads
+to check preparation, error feedback and recovery through Retry, including a
+second failure after importing a backup into an already loaded session. These use
+controlled IPC faults rather than adding a large, timing-dependent database to CI.
+Local macOS Electron verification does not substitute for installed
Linux Mint MATE, Snap, or Flatpak upgrade testing.
## Module Structure
diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts
index cbd8c28c2..f7c12b267 100644
--- a/libs/m3u-state/src/lib/actions.ts
+++ b/libs/m3u-state/src/lib/actions.ts
@@ -12,6 +12,7 @@ export const PlaylistActions = createActionGroup({
events: {
'Load Playlists': emptyProps(),
'Load Playlists Success': props<{ playlists: PlaylistMeta[] }>(),
+ 'Load Playlists Failure': emptyProps(),
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts
index 40ab30ead..5b3e0ed90 100644
--- a/libs/m3u-state/src/lib/effects.ts
+++ b/libs/m3u-state/src/lib/effects.ts
@@ -13,18 +13,23 @@ import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import {
EMPTY,
+ catchError,
concatMap,
+ defer,
filter,
firstValueFrom,
from,
map,
mergeMap,
+ of,
+ retry,
switchMap,
tap,
withLatestFrom,
} from 'rxjs';
import {
DataService,
+ EpgSourceSettingsService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
@@ -72,6 +77,7 @@ export class PlaylistEffects {
private store = inject(Store);
private translate = inject(TranslateService);
private settingsStore = inject(SettingsStore);
+ private epgSources = inject(EpgSourceSettingsService);
private readonly playlistScopedEpgFetchKeys = new Map();
updateFavorites$ = createEffect(
@@ -257,7 +263,19 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
- this.playlistsService.getAllPlaylists().pipe(
+ defer(() => this.playlistsService.getAllPlaylists()).pipe(
+ // Recreate the storage request once for transient failures.
+ // A final failure is state, not an empty source inventory.
+ retry({ count: 1, delay: 300 }),
+ switchMap((playlists) =>
+ defer(async () => {
+ // Settings can register initial cleanup after the
+ // faster inventory read has already completed.
+ await this.settingsStore.loadSettings();
+ await this.epgSources.retryFailedReconciliation();
+ return playlists;
+ })
+ ),
tap((playlists) => {
this.fetchPlaylistScopedEpgForPlaylists(playlists);
}),
@@ -265,7 +283,8 @@ export class PlaylistEffects {
PlaylistActions.loadPlaylistsSuccess({
playlists,
})
- )
+ ),
+ catchError(() => of(PlaylistActions.loadPlaylistsFailure()))
)
)
);
diff --git a/libs/m3u-state/src/lib/load-playlists.effect.spec.ts b/libs/m3u-state/src/lib/load-playlists.effect.spec.ts
new file mode 100644
index 000000000..5e1959077
--- /dev/null
+++ b/libs/m3u-state/src/lib/load-playlists.effect.spec.ts
@@ -0,0 +1,174 @@
+import { Injector, runInInjectionContext } from '@angular/core';
+import { Router } from '@angular/router';
+import { Actions } from '@ngrx/effects';
+import { Store } from '@ngrx/store';
+import { EpgService } from '@iptvnator/epg/data-access';
+import {
+ EpgSourceSettingsService,
+ PlaylistsService,
+ SettingsStore,
+} from '@iptvnator/services';
+import { EMPTY, of, Subject, throwError } from 'rxjs';
+import { PlaylistActions } from './actions';
+import { PlaylistEffects } from './effects';
+
+// Each subscription must create a fresh storage request, not replay the same
+// rejected promise. Exhausted retries must leave the action stream usable.
+describe('PlaylistEffects loadPlaylists$', () => {
+ let actions$: Subject;
+ let getAllPlaylists: jest.Mock;
+ let effects: PlaylistEffects;
+ let retryFailedReconciliation: jest.Mock;
+ let loadSettings: jest.Mock;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ actions$ = new Subject();
+ getAllPlaylists = jest.fn(() => of([]));
+ retryFailedReconciliation = jest.fn().mockResolvedValue(undefined);
+ loadSettings = jest.fn().mockResolvedValue(undefined);
+ const injector = Injector.create({
+ parent: { get: () => ({}) } as unknown as Injector,
+ providers: [
+ { provide: Actions, useValue: new Actions(actions$) },
+ { provide: Store, useValue: { select: () => EMPTY } },
+ { provide: PlaylistsService, useValue: { getAllPlaylists } },
+ { provide: EpgService, useValue: { fetchEpg: jest.fn() } },
+ { provide: Router, useValue: {} },
+ {
+ provide: EpgSourceSettingsService,
+ useValue: { retryFailedReconciliation },
+ },
+ {
+ provide: SettingsStore,
+ useValue: {
+ loadSettings,
+ getSettings: () => ({ epgUrl: [] }),
+ },
+ },
+ ],
+ });
+ effects = runInInjectionContext(injector, () => new PlaylistEffects());
+ });
+
+ afterEach(() => jest.useRealTimers());
+
+ it('waits for slow settings to register initial cleanup before publishing inventory', async () => {
+ let finishSettings!: () => void;
+ loadSettings.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishSettings = resolve;
+ })
+ );
+ const results: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe((value) =>
+ results.push(value)
+ );
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(getAllPlaylists).toHaveBeenCalledTimes(1);
+ expect(results).toEqual([]);
+ expect(retryFailedReconciliation).not.toHaveBeenCalled();
+ // SettingsStore completes after swallowing the first cleanup failure.
+ retryFailedReconciliation.mockImplementationOnce(() =>
+ Promise.reject(new Error('cleanup still unavailable'))
+ );
+ finishSettings();
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([PlaylistActions.loadPlaylistsFailure()]);
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results.at(-1)).toEqual(
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ subscription.unsubscribe();
+ });
+
+ it('waits for failed EPG reconciliation to recover before exposing sources', async () => {
+ let finish!: () => void;
+ retryFailedReconciliation.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ })
+ );
+ const results: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe((value) =>
+ results.push(value)
+ );
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([]);
+ finish();
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] }),
+ ]);
+ subscription.unsubscribe();
+ });
+
+ it('keeps a failed EPG recovery actionable and retries it on the next load', async () => {
+ retryFailedReconciliation.mockImplementationOnce(() =>
+ Promise.reject(new Error('cleanup failed'))
+ );
+ const results: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe((value) =>
+ results.push(value)
+ );
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([PlaylistActions.loadPlaylistsFailure()]);
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results.at(-1)).toEqual(
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ expect(retryFailedReconciliation).toHaveBeenCalledTimes(2);
+ subscription.unsubscribe();
+ });
+
+ it('recovers a transient read error without restarting', async () => {
+ getAllPlaylists.mockReturnValueOnce(
+ throwError(() => new Error('temporary'))
+ );
+ const results: unknown[] = [];
+ const errors: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe({
+ next: (value) => results.push(value),
+ error: (error) => errors.push(error),
+ });
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(errors).toEqual([]);
+ expect(getAllPlaylists).toHaveBeenCalledTimes(2);
+ expect(results).toEqual([
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] }),
+ ]);
+ subscription.unsubscribe();
+ });
+
+ it('reports persistent failure without treating it as an empty library and accepts Retry', async () => {
+ getAllPlaylists.mockReturnValue(
+ throwError(() => new Error('private storage error'))
+ );
+ const results: unknown[] = [];
+ const errors: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe({
+ next: (value) => results.push(value),
+ error: (error) => errors.push(error),
+ });
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(errors).toEqual([]);
+ expect(getAllPlaylists).toHaveBeenCalledTimes(2);
+ expect(results).toEqual([
+ { type: '[Playlists] Load Playlists Failure' },
+ ]);
+ getAllPlaylists.mockReturnValue(of([]));
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results.at(-1)).toEqual(
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ subscription.unsubscribe();
+ });
+});
diff --git a/libs/m3u-state/src/lib/playlists.state.ts b/libs/m3u-state/src/lib/playlists.state.ts
index 2b4e48568..4a46a04ad 100644
--- a/libs/m3u-state/src/lib/playlists.state.ts
+++ b/libs/m3u-state/src/lib/playlists.state.ts
@@ -9,6 +9,7 @@ export const playlistsAdapter: EntityAdapter =
export interface PlaylistMetaState extends EntityState {
selectedId: string;
allPlaylistsLoaded: boolean;
+ loadFailed: boolean;
selectedFilters: string[];
}
@@ -16,6 +17,7 @@ export const initialPlaylistMetaState: PlaylistMetaState =
playlistsAdapter.getInitialState({
selectedId: '',
allPlaylistsLoaded: false,
+ loadFailed: false,
selectedFilters: ['m3u', 'xtream', 'stalker'],
});
diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
index da5469cc8..03cc0610a 100644
--- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
+++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
@@ -8,6 +8,46 @@ import { Channel, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
const reducer = createReducer(initialState, ...playlistReducers);
describe('playlistReducers', () => {
+ it('keeps a failed initial inventory unready until a retry succeeds', () => {
+ const failed = reducer(
+ initialState,
+ PlaylistActions.loadPlaylistsFailure()
+ );
+ expect(failed.playlists.loadFailed).toBe(true);
+ expect(failed.playlists.allPlaylistsLoaded).toBe(false);
+ const retrying = reducer(failed, PlaylistActions.loadPlaylists());
+ expect(retrying.playlists.loadFailed).toBe(false);
+ expect(retrying.playlists.allPlaylistsLoaded).toBe(false);
+ const recovered = reducer(
+ retrying,
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ expect(recovered.playlists.loadFailed).toBe(false);
+ expect(recovered.playlists.allPlaylistsLoaded).toBe(true);
+ });
+
+ it('clears readiness when a backup replaces an already loaded inventory', () => {
+ const loaded = reducer(
+ initialState,
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ const emptied = reducer(loaded, PlaylistActions.removeAllPlaylists());
+ const loading = reducer(emptied, PlaylistActions.loadPlaylists());
+ expect(loading.playlists.allPlaylistsLoaded).toBe(false);
+ const failed = reducer(loading, PlaylistActions.loadPlaylistsFailure());
+ expect(failed.playlists.allPlaylistsLoaded).toBe(false);
+ expect(failed.playlists.loadFailed).toBe(true);
+ const retrying = reducer(failed, PlaylistActions.loadPlaylists());
+ expect(retrying.playlists.allPlaylistsLoaded).toBe(false);
+ expect(retrying.playlists.loadFailed).toBe(false);
+ const recovered = reducer(
+ retrying,
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ expect(recovered.playlists.allPlaylistsLoaded).toBe(true);
+ expect(recovered.playlists.loadFailed).toBe(false);
+ });
+
it('persists updateDate and hiddenGroupTitles when playlist meta is updated', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
index 0169c11e4..e3bd19ead 100644
--- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
+++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
@@ -6,12 +6,25 @@ import { playlistsAdapter } from '../playlists.state';
import { PlaylistState } from '../state';
export const playlistReducers = [
+ on(PlaylistActions.loadPlaylists, (state): PlaylistState => ({
+ ...state,
+ playlists: {
+ ...state.playlists,
+ allPlaylistsLoaded: false,
+ loadFailed: false,
+ },
+ })),
+ on(PlaylistActions.loadPlaylistsFailure, (state): PlaylistState => ({
+ ...state,
+ playlists: { ...state.playlists, loadFailed: true },
+ })),
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(action.playlists, {
...state.playlists,
allPlaylistsLoaded: true,
+ loadFailed: false,
}),
};
}),
diff --git a/libs/m3u-state/src/lib/selectors.ts b/libs/m3u-state/src/lib/selectors.ts
index 0aad2b321..be8bdaec9 100644
--- a/libs/m3u-state/src/lib/selectors.ts
+++ b/libs/m3u-state/src/lib/selectors.ts
@@ -53,6 +53,11 @@ export const selectPlaylistsMetaState = createSelector(
fromPlaylistState.selectPlaylists
);
+export const selectPlaylistsLoadFailed = createSelector(
+ selectPlaylistsMetaState,
+ (state) => state.loadFailed === true
+);
+
export const selectAllPlaylistsMeta = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getAllPlaylistsMeta
diff --git a/libs/services/src/lib/epg-source-settings.service.spec.ts b/libs/services/src/lib/epg-source-settings.service.spec.ts
index 0bd81c73c..08bebc9d6 100644
--- a/libs/services/src/lib/epg-source-settings.service.spec.ts
+++ b/libs/services/src/lib/epg-source-settings.service.spec.ts
@@ -1,5 +1,5 @@
import { Injector } from '@angular/core';
-import { firstValueFrom, of, Subject } from 'rxjs';
+import { firstValueFrom, of, Subject, throwError } from 'rxjs';
import { EpgSourceSettingsService } from './epg-source-settings.service';
import { PlaylistsService } from './playlists.service';
@@ -109,4 +109,70 @@ describe('EPG source settings synchronization', () => {
]);
expect(service.retainCurrentSources(['a', 'b'], 0)).toEqual(['b']);
});
+ it('retries failed inventory ownership before pruning, using the committed URLs', async () => {
+ const getAllPlaylists = jest
+ .fn()
+ .mockReturnValue(throwError(() => new Error('disk')));
+ const reconcileEpgSources = jest
+ .fn()
+ .mockResolvedValue({ success: true });
+ window.electron = {
+ reconcileEpgSources,
+ } as unknown as typeof window.electron;
+ const injector = Injector.create({
+ providers: [
+ EpgSourceSettingsService,
+ { provide: PlaylistsService, useValue: { getAllPlaylists } },
+ ],
+ });
+ const service = injector.get(EpgSourceSettingsService);
+ // No known committed settings means no permission to prune defaults.
+ await service.retryFailedReconciliation();
+ expect(getAllPlaylists).not.toHaveBeenCalled();
+ await expect(
+ service.synchronize([' current ', 'current'])
+ ).rejects.toThrow();
+ expect(reconcileEpgSources).not.toHaveBeenCalled();
+ getAllPlaylists.mockReturnValue(of([]));
+ await service.retryFailedReconciliation();
+ expect(reconcileEpgSources).toHaveBeenCalledWith(['current']);
+ await service.retryFailedReconciliation();
+ expect(reconcileEpgSources).toHaveBeenCalledTimes(1);
+ });
+
+ it('waits for a newer committed synchronization without replaying an older failed set', async () => {
+ let finish!: (result: { success: boolean }) => void;
+ const reconcileEpgSources = jest
+ .fn()
+ .mockResolvedValueOnce({ success: false })
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ })
+ );
+ window.electron = {
+ reconcileEpgSources,
+ } as unknown as typeof window.electron;
+ const injector = Injector.create({
+ providers: [
+ EpgSourceSettingsService,
+ {
+ provide: PlaylistsService,
+ useValue: { getAllPlaylists: () => of([]) },
+ },
+ ],
+ });
+ const service = injector.get(EpgSourceSettingsService);
+ await expect(service.synchronize(['old'])).rejects.toThrow();
+ const recovery = service.retryFailedReconciliation();
+ const latest = service.synchronize(['new']);
+ await Promise.resolve();
+ finish({ success: true });
+ await Promise.all([latest, recovery]);
+ expect(reconcileEpgSources.mock.calls).toEqual([[['old']], [['new']]]);
+ expect(service.retainCurrentSources(['old', 'new'], 0)).toEqual([
+ 'new',
+ ]);
+ });
});
diff --git a/libs/services/src/lib/epg-source-settings.service.ts b/libs/services/src/lib/epg-source-settings.service.ts
index 129d84789..012ed7cdd 100644
--- a/libs/services/src/lib/epg-source-settings.service.ts
+++ b/libs/services/src/lib/epg-source-settings.service.ts
@@ -42,6 +42,7 @@ export class EpgSourceSettingsService {
private readonly injector = inject(Injector);
private activeUrls = new Set();
private reconciliation: Promise | undefined;
+ private failedReconciliation: { urls: string[] } | undefined;
readonly revision = signal(0);
readonly changed$ = new Subject();
@@ -66,6 +67,18 @@ export class EpgSourceSettingsService {
}
}
+ /** A recovered inventory must finish any previously authorized cleanup. */
+ async retryFailedReconciliation(): Promise {
+ // A save can start between the wait resolving and this continuation.
+ // Recheck ownership before scheduling a retry of the failed URL set.
+ do {
+ await this.waitForReconciliation();
+ } while (this.reconciliation);
+ if (this.failedReconciliation) {
+ await this.synchronize(this.failedReconciliation.urls);
+ }
+ }
+
async synchronize(urls: string[] | string | undefined): Promise {
if (
typeof window === 'undefined' ||
@@ -108,7 +121,9 @@ export class EpgSourceSettingsService {
await window.electron.reconcileEpgSources(normalized);
if (!result.success)
throw new Error('EPG source reconciliation failed');
+ this.failedReconciliation = undefined;
} catch {
+ this.failedReconciliation = { urls: normalized };
throw new EpgSourceReconciliationError();
} finally {
this.revision.update((revision) => revision + 1);
diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts
index 70af02958..cfbe7dd0e 100644
--- a/tools/release/capture-app-driver.ts
+++ b/tools/release/capture-app-driver.ts
@@ -40,8 +40,19 @@ export {
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
-/** Synthetic categories that only the marketing fixture generator produces. */
-const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama'];
+/**
+ * Synthetic categories that only the marketing fixture generator produces,
+ * per catalog. They must be checked against their OWN endpoint: a series
+ * category can never appear in `get_vod_categories`, and asserting it there
+ * made the reuse path below reject every already-running mock.
+ */
+const MOCK_FIXTURE_CATEGORIES: ReadonlyArray<{
+ action: 'get_vod_categories' | 'get_series_categories';
+ name: string;
+}> = [
+ { action: 'get_vod_categories', name: 'Action & Mystery' },
+ { action: 'get_series_categories', name: 'Urban Drama' },
+];
/**
* Live categories of the Stalker mock's marketing-demo scenario, from
* `MARKETING_LIVE_CATEGORIES` in `@iptvnator/shared/marketing-fixtures`.
@@ -203,27 +214,27 @@ async function assertStalkerMockServerIdentity(): Promise {
* the marketing catalog with the exact synthetic titles the shots rely on.
*/
async function assertMockServerIdentity(): Promise {
- const response = await fetch(
- `${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=get_vod_categories`
- ).catch(() => null);
-
- if (!response?.ok) {
- throw new Error(
- `Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.`
- );
- }
-
- const categories = (await response.json().catch(() => null)) as
- | { category_name?: string }[]
- | null;
- const names = new Set(
- (categories ?? []).map((entry) => entry.category_name)
- );
-
for (const expected of MOCK_FIXTURE_CATEGORIES) {
- if (!names.has(expected)) {
+ const response = await fetch(
+ `${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=${expected.action}`
+ ).catch(() => null);
+
+ if (!response?.ok) {
throw new Error(
- `The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing category "${expected}"). Refusing to capture screenshots from unknown data.`
+ `Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.`
+ );
+ }
+
+ const categories = (await response.json().catch(() => null)) as
+ | { category_name?: string }[]
+ | null;
+ const names = new Set(
+ (categories ?? []).map((entry) => entry.category_name)
+ );
+
+ if (!names.has(expected.name)) {
+ throw new Error(
+ `The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing ${expected.action} entry "${expected.name}"). Refusing to capture screenshots from unknown data.`
);
}
}
diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts
index a06c9a0e7..cfda0f827 100644
--- a/tools/release/capture-fixtures.ts
+++ b/tools/release/capture-fixtures.ts
@@ -60,6 +60,8 @@ export const CAPTURE_DOWNLOAD_FOLDER_NAME = 'IPTVnator downloads';
export const M3U_FIXTURE_PLAYLIST_URL = `${XTREAM_MOCK_ORIGIN}/demo/channels.m3u8`;
export const M3U_FIXTURE_PLAYLIST_TITLE = 'Fictional TV playlist';
export const EPG_FIXTURE_URL = `${XTREAM_MOCK_ORIGIN}/demo/guide.xml.gz`;
+/** Served by the Xtream mock (`/demo/guide.xml`); the EPG-mapping guide shots import it for real. */
+export const DEMO_EPG_URL = `${XTREAM_MOCK_ORIGIN}/demo/guide.xml`;
/** A fictional "your subscription is ready" message for the Auto-detect shot. */
export const AUTO_DETECT_FIXTURE_MESSAGE = [
diff --git a/tools/release/capture-navigation-epg-actions.ts b/tools/release/capture-navigation-epg-actions.ts
new file mode 100644
index 000000000..e6533e030
--- /dev/null
+++ b/tools/release/capture-navigation-epg-actions.ts
@@ -0,0 +1,121 @@
+/**
+ * Setup actions for the EPG-mapping guide shots: importing the mock's XMLTV
+ * guide, opening a channel's context menu and staging the mapping dialog.
+ * They sit between the settings pages (`capture-navigation-setup-actions.ts`)
+ * and the channel lists (`capture-navigation-portal-actions.ts`), so they
+ * borrow one entry point from each rather than duplicating the navigation.
+ */
+
+import type { Page } from '@playwright/test';
+
+import { DEMO_EPG_URL } from './capture-fixtures';
+import { type CaptureAction, settleUi } from './capture-navigation-helpers';
+import { openM3uGroups } from './capture-navigation-portal-actions';
+import { openEpgSettingsSection } from './capture-navigation-setup-actions';
+
+async function openSettingsEpgOffset(page: Page): Promise {
+ await openEpgSettingsSection(page);
+ const offset = page.locator('[data-test-id="epg-offset-minutes"]');
+
+ await offset.waitFor({ state: 'visible', timeout: 10_000 });
+ // Staged in the form only; discarded before the next action.
+ await offset.fill('60');
+ await page.waitForTimeout(400);
+}
+
+/**
+ * Imports the mock's XMLTV guide as an EPG source and saves the settings, so
+ * the mapping dialog has channels to search. Idempotent: a saved row with the
+ * demo URL means the guide is already in the isolated database.
+ */
+async function loadDemoEpg(page: Page): Promise {
+ await openEpgSettingsSection(page);
+ const section = page.locator('#epg');
+ const existing = await section
+ .locator('.epg-source-row input')
+ .evaluateAll((inputs) =>
+ inputs.map((input) => (input as HTMLInputElement).value)
+ );
+
+ if (existing.includes(DEMO_EPG_URL)) {
+ return;
+ }
+
+ await section
+ .getByRole('button', { name: /add epg source/i })
+ .click({ timeout: 10_000 });
+ const field = section.locator('.epg-source-row input').last();
+ await field.waitFor({ state: 'visible', timeout: 10_000 });
+ await field.fill(DEMO_EPG_URL, { timeout: 10_000 });
+ // The row's first icon button fetches the source right away.
+ await section
+ .locator('.epg-source-row')
+ .last()
+ .locator('button')
+ .first()
+ .click();
+ // A loopback source trips the app's private-network confirmation; the
+ // mock is the one private address this capture trusts.
+ const allow = page.getByRole('button', { name: /allow source/i });
+
+ if (
+ await allow
+ .waitFor({ state: 'visible', timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false)
+ ) {
+ await allow.click();
+ }
+
+ await page
+ .locator(
+ '.epg-progress-panel .import-item.status-complete, .epg-progress-panel .stat-badge'
+ )
+ .first()
+ .waitFor({ state: 'visible', timeout: 60_000 });
+
+ const save = page.locator('[data-test-id="save-settings"]').first();
+
+ if (await save.isEnabled().catch(() => false)) {
+ await save.click();
+ }
+
+ await settleUi(page);
+}
+
+/** Right-clicks the first channel of the M3U groups view and waits for its menu. */
+async function openM3uChannelContextMenu(page: Page): Promise {
+ await openM3uGroups(page);
+ const channel = page.locator('app-channel-list-item').first();
+
+ await channel.waitFor({ state: 'visible', timeout: 30_000 });
+ await channel.click({ button: 'right' });
+ await page
+ .getByRole('menuitem', { name: /map epg channel/i })
+ .waitFor({ state: 'visible', timeout: 10_000 });
+ await page.waitForTimeout(400);
+}
+
+async function openEpgMappingDialog(page: Page): Promise {
+ await loadDemoEpg(page);
+ await openM3uChannelContextMenu(page);
+ await page.getByRole('menuitem', { name: /map epg channel/i }).click();
+
+ const dialog = page.locator('mat-dialog-container').last();
+
+ await dialog.waitFor({ state: 'visible', timeout: 15_000 });
+ await dialog.locator('input').first().fill('Aurora');
+
+ const result = dialog.locator('.epg-mapping-result').first();
+
+ await result.waitFor({ state: 'visible', timeout: 15_000 });
+ await result.click();
+ await page.waitForTimeout(500);
+}
+
+export const EPG_ACTIONS: Readonly> = {
+ 'open-settings-epg-offset': openSettingsEpgOffset,
+ 'load-demo-epg': loadDemoEpg,
+ 'open-m3u-channel-menu': openM3uChannelContextMenu,
+ 'open-epg-mapping-dialog': openEpgMappingDialog,
+};
diff --git a/tools/release/capture-navigation-portal-actions.ts b/tools/release/capture-navigation-portal-actions.ts
index 2f35704d6..7ae2ecf1a 100644
--- a/tools/release/capture-navigation-portal-actions.ts
+++ b/tools/release/capture-navigation-portal-actions.ts
@@ -58,7 +58,7 @@ export async function openXtreamSeries(
await page.waitForTimeout(700);
}
-async function openM3uGroups(page: Page): Promise {
+export async function openM3uGroups(page: Page): Promise {
const playlistId = requirePlaylistId('playlists');
await goHome(page);
diff --git a/tools/release/capture-navigation-setup-actions.ts b/tools/release/capture-navigation-setup-actions.ts
index b8b8bc69a..dfaf4f14c 100644
--- a/tools/release/capture-navigation-setup-actions.ts
+++ b/tools/release/capture-navigation-setup-actions.ts
@@ -129,7 +129,8 @@ async function openAddPlaylistM3uUrl(page: Page): Promise {
/* Settings sections */
/* ------------------------------------------------------------------ */
-async function openSettingsEpg(page: Page): Promise {
+/** Navigates to Settings › EPG and waits for the section, without staging anything. */
+export async function openEpgSettingsSection(page: Page): Promise {
await openSettings(page);
const sectionLink = page
.locator('[data-test-id="settings-section-epg"]')
@@ -138,20 +139,28 @@ async function openSettingsEpg(page: Page): Promise {
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/epg/, { timeout: 15_000 });
+ await page.locator('#epg').waitFor({ state: 'visible', timeout: 15_000 });
+}
+async function openSettingsEpg(page: Page): Promise {
+ await openEpgSettingsSection(page);
const section = page.locator('#epg');
- await section.waitFor({ state: 'visible', timeout: 15_000 });
+
// Show a filled source row instead of the empty state. The value
// is staged in the form only; nothing is saved or fetched. The
// dirty form is discarded by `discardUnsavedSettings` before the
// next action or the app teardown — the settings close guard
- // would otherwise hold `app.close()` open forever.
- await section
- .getByRole('button', { name: /add epg source/i })
- .click({ timeout: 10_000 });
- const field = section.locator('input[type="url"]').last();
- await field.waitFor({ state: 'visible', timeout: 10_000 });
- await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
+ // would otherwise hold `app.close()` open forever. A row a previous
+ // step already saved (`load-demo-epg`) fills the frame on its own.
+ if ((await section.locator('.epg-source-row').count()) === 0) {
+ await section
+ .getByRole('button', { name: /add epg source/i })
+ .click({ timeout: 10_000 });
+ const field = section.locator('input[type="url"]').last();
+ await field.waitFor({ state: 'visible', timeout: 10_000 });
+ await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
+ }
+
await page.waitForTimeout(500);
}
diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts
index 6238fb738..6c9e0e8d4 100644
--- a/tools/release/capture-navigation.ts
+++ b/tools/release/capture-navigation.ts
@@ -7,7 +7,8 @@
* (`capture-navigation-setup-actions.ts`: dialogs, settings, remote
* control; `capture-navigation-portal-actions.ts`: portal browsing and
* alternative sources; `capture-navigation-download-actions.ts`: the
- * download manager) over the shared page helpers in
+ * download manager; `capture-navigation-epg-actions.ts`: the EPG guide
+ * import and channel mapping) over the shared page helpers in
* `capture-navigation-helpers.ts`. This file only dispatches a step name
* and re-exports the API the seeding driver and the capture script use.
*/
@@ -21,6 +22,7 @@ import {
settleUi,
} from './capture-navigation-helpers';
import { DOWNLOAD_ACTIONS } from './capture-navigation-download-actions';
+import { EPG_ACTIONS } from './capture-navigation-epg-actions';
import { PORTAL_ACTIONS } from './capture-navigation-portal-actions';
import { SETUP_ACTIONS } from './capture-navigation-setup-actions';
@@ -38,6 +40,7 @@ const ACTIONS: Readonly> = {
...SETUP_ACTIONS,
...PORTAL_ACTIONS,
...DOWNLOAD_ACTIONS,
+ ...EPG_ACTIONS,
};
/* ------------------------------------------------------------------ */
diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs
index 89b04f86f..5ce15ea00 100644
--- a/tools/release/screenshot-guards.mjs
+++ b/tools/release/screenshot-guards.mjs
@@ -59,6 +59,10 @@ export const KNOWN_ACTIONS = [
'open-xtream-live-channel',
'open-settings-remote-control',
'enable-remote-control',
+ 'open-settings-epg-offset',
+ 'load-demo-epg',
+ 'open-m3u-channel-menu',
+ 'open-epg-mapping-dialog',
];
/**
diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json
index cb5d448c9..137b169a1 100644
--- a/tools/release/screenshots.manifest.json
+++ b/tools/release/screenshots.manifest.json
@@ -171,6 +171,30 @@
"height": 1100
}
}
+ },
+ {
+ "slug": "guide-epg-map-menu",
+ "title": "Channel context menu with Map EPG channel",
+ "group": "guides",
+ "setup": [
+ "open-m3u-channel-menu"
+ ]
+ },
+ {
+ "slug": "guide-epg-map-dialog",
+ "title": "Map EPG channel dialog with a search result",
+ "group": "guides",
+ "setup": [
+ "open-epg-mapping-dialog"
+ ]
+ },
+ {
+ "slug": "guide-epg-offset",
+ "title": "Settings: EPG time offset",
+ "group": "guides",
+ "setup": [
+ "open-settings-epg-offset"
+ ]
}
]
}
diff --git a/tools/testing/website-guides.test.mjs b/tools/testing/website-guides.test.mjs
index cd51708e7..513c0dec8 100644
--- a/tools/testing/website-guides.test.mjs
+++ b/tools/testing/website-guides.test.mjs
@@ -57,6 +57,14 @@ const GUIDES = [
'blog/guides/screenshots/guide-remote-phone-dark.png',
],
},
+ {
+ slug: 'epg-wrong-program-fix',
+ screenshots: [
+ 'blog/guides/screenshots/guide-epg-map-menu-dark.png',
+ 'blog/guides/screenshots/guide-epg-map-dialog-dark.png',
+ 'blog/guides/screenshots/guide-epg-offset-dark.png',
+ ],
+ },
];
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');