From 7f06690e72031fb21db46eeca9b38a22e89de6c5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:29:11 +0200 Subject: [PATCH] feat(epg): copy catch-up programme URLs (#1569) * feat(epg): copy catch-up programme URLs without changing playback * fix(epg): let newer archive copy requests supersede pending work --- .changes/epg-copy-archive-url.md | 6 + AGENTS.md | 8 + CLAUDE.md | 8 + .../src/xtream-catchup-timezone.e2e.ts | 69 +++++++ apps/web/src/assets/i18n/ar.json | 6 +- apps/web/src/assets/i18n/ary.json | 6 +- apps/web/src/assets/i18n/by.json | 6 +- apps/web/src/assets/i18n/de.json | 6 +- apps/web/src/assets/i18n/el.json | 6 +- apps/web/src/assets/i18n/en.json | 6 +- apps/web/src/assets/i18n/es.json | 6 +- apps/web/src/assets/i18n/fr.json | 6 +- apps/web/src/assets/i18n/hu.json | 6 +- apps/web/src/assets/i18n/it.json | 6 +- apps/web/src/assets/i18n/ja.json | 6 +- apps/web/src/assets/i18n/ko.json | 6 +- apps/web/src/assets/i18n/nl.json | 6 +- apps/web/src/assets/i18n/pl.json | 6 +- apps/web/src/assets/i18n/pt.json | 6 +- apps/web/src/assets/i18n/ru.json | 6 +- apps/web/src/assets/i18n/tr.json | 6 +- apps/web/src/assets/i18n/zh.json | 6 +- apps/web/src/assets/i18n/zhtw.json | 6 +- docs/architecture/m3u-playlist-module.md | 18 ++ .../xtream-portal-compatibility.md | 5 + .../video-player-guide-gating.spec.ts | 26 +++ .../video-player/video-player.component.html | 2 + .../video-player/video-player.component.ts | 17 ++ .../video-player/video-player.spec-stubs.ts | 1 + .../unified-live-tab.component.html | 2 + .../unified-live-tab.component.spec.ts | 173 ++++++------------ .../unified-live-tab.component.ts | 36 ++++ .../unified-live-tab.spec-data.ts | 119 ++++++++++++ .../unified-live-tab.spec-stubs.ts | 1 + ...e-stream-auto-open-queue.component.spec.ts | 5 + .../live-stream-layout-stubs.spec-data.ts | 1 + .../live-stream-layout.component.html | 2 + .../live-stream-layout.component.spec.ts | 51 +++++- .../live-stream-layout.component.ts | 33 ++++ .../live-stream-layout.sidebar-levels.spec.ts | 10 +- libs/ui/epg/src/index.ts | 2 + .../src/lib/epg-archive-copy.service.spec.ts | 105 +++++++++++ .../epg/src/lib/epg-archive-copy.service.ts | 39 ++++ .../epg-item-description.component.html | 20 +- .../epg-item-description.component.scss | 1 + .../epg-item-description.component.spec.ts | 22 +++ .../epg-item-description.component.ts | 17 +- .../epg-list-view.component.spec.ts | 52 +++++- .../epg-list-view/epg-list-view.component.ts | 15 +- .../src/lib/epg-program-activation-event.ts | 2 +- .../epg-timeline.component.spec.ts | 52 ++++++ .../epg-timeline/epg-timeline.component.ts | 18 +- 52 files changed, 894 insertions(+), 158 deletions(-) create mode 100644 .changes/epg-copy-archive-url.md create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-data.ts create mode 100644 libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-archive-copy.service.ts diff --git a/.changes/epg-copy-archive-url.md b/.changes/epg-copy-archive-url.md new file mode 100644 index 000000000..36ef5fd2f --- /dev/null +++ b/.changes/epg-copy-archive-url.md @@ -0,0 +1,6 @@ +--- +type: feature +area: epg +--- + +Copy a catch-up programme’s URL from its EPG details in Xtream and supported M3U sources, including Favorites and Recent, without interrupting playback. The link can be used in an external player or download tool. diff --git a/AGENTS.md b/AGENTS.md index 7bbf8052e..f04dd31be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1027,3 +1027,11 @@ do not duplicate them here. - The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax + +## Catch-Up URL Copying + +EPG timeline/list programme details expose Copy archive URL for supported +Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns +clipboard feedback; hosts resolve URLs without mutating playback. Stalker and +the currently non-catch-up M3U guide expose no action. See +`docs/architecture/m3u-playlist-module.md` (Copy archive URL). diff --git a/CLAUDE.md b/CLAUDE.md index 5ed3973a9..e7008f398 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1838,3 +1838,11 @@ beside the resize handle. `PortalDetailShellComponent` owns a visible native scrollbar and guarded initial page focus. Contracts: `docs/architecture/iptvnator-ui-guidelines.md` and `docs/architecture/portal-detail-navigation.md`. + +## Catch-Up URL Copying + +EPG timeline/list programme details expose Copy archive URL for supported +Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns +clipboard feedback; hosts resolve URLs without mutating playback. Stalker and +the currently non-catch-up M3U guide expose no action. See +`docs/architecture/m3u-playlist-module.md` (Copy archive URL). diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts index a90d8be04..98023d02b 100644 --- a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts @@ -239,3 +239,72 @@ test('@epg @xtream @electron derives the panel offset from its clock pair when t await closeElectronApp(app); } }); + +test('@epg @xtream @electron copies the archive URL without starting archive playback', async ({ + dataDir, + request, +}) => { + test.setTimeout(120000); + const credentials = { username: 'epg', password: 'epg' }; + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, credentials); + const app = await launchElectronApp(dataDir, { + env: { TZ: VIEWER_TIMEZONE }, + }); + try { + await addXtreamPortal(app.mainWindow, { + name: 'Copy archive URL', + ...credentials, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName); + const captured = captureTimeshiftRequests(app.mainWindow); + const block = app.mainWindow + .locator('app-epg-timeline .epg-timeline__block') + .filter({ hasText: PAST_PROGRAM }) + .first(); + await expect(block).toBeVisible({ timeout: 20000 }); + await block.locator('.epg-timeline__info').click(); + const dialog = app.mainWindow.getByRole('dialog'); + await expect( + dialog.getByText(/URL may contain account credentials/) + ).toBeVisible(); + for (const dark of [false, true]) { + await app.mainWindow.evaluate((enabled) => { + document.body.classList.toggle('dark-theme', enabled); + }, dark); + await expect( + dialog.getByRole('button', { + name: 'Copy archive URL', + exact: true, + }) + ).toBeInViewport(); + await dialog.screenshot({ + path: test + .info() + .outputPath(`archive-copy-${dark ? 'dark' : 'light'}.png`), + }); + } + await dialog + .getByRole('button', { name: 'Copy archive URL', exact: true }) + .click(); + await expect( + app.mainWindow.getByText('Archive URL copied', { exact: true }) + ).toBeVisible(); + const url = await app.electronApp.evaluate(({ clipboard }) => + clipboard.readText() + ); + expect(url).toContain('/timeshift/epg/epg/'); + const start = TIMESHIFT_SEGMENT.exec(url)?.[1]; + expect(start).toBeTruthy(); + await expectServerClock( + start!, + [await pastProgramWindows(request, credentials)], + 0 + ); + // Main-process archive probes do not appear as renderer playback requests. + expect(captured).toEqual([]); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 967dd8425..f4d370b13 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -792,7 +792,11 @@ "LANGUAGE": "اللغة", "SHOW_PROGRAM_DETAILS": "عرض تفاصيل هذا البرنامج", "EPISODE": "الحلقة", - "DURATION": "المدة" + "DURATION": "المدة", + "COPY_ARCHIVE_URL": "نسخ رابط الأرشيف", + "ARCHIVE_URL_COPIED": "تم نسخ رابط الأرشيف", + "ARCHIVE_URL_FAILED": "تعذّر نسخ رابط الأرشيف", + "ARCHIVE_URL_NOTICE": "قد يحتوي الرابط على بيانات الدخول. قد تحتاج المشغلات الخارجية أيضًا إلى ترويسات HTTP الخاصة بالمصدر." }, "FETCH_SUCCESS": "تم جلب الدليل بنجاح.", "IMPORT_PROGRESS": "استيراد EPG", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index cde4f2d64..17d169646 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -792,7 +792,11 @@ "LANGUAGE": "اللغة", "SHOW_PROGRAM_DETAILS": "وري تفاصيل هاد البرنامج", "EPISODE": "الحلقة", - "DURATION": "المدة" + "DURATION": "المدة", + "COPY_ARCHIVE_URL": "نسخ رابط الأرشيف", + "ARCHIVE_URL_COPIED": "تنسخ رابط الأرشيف", + "ARCHIVE_URL_FAILED": "ما قدرناش ننسخو رابط الأرشيف", + "ARCHIVE_URL_NOTICE": "الرابط يقدر يكون فيه معلومات الدخول. المشغلات الخارجية تقدر تحتاج حتى ترويسات HTTP ديال المصدر." }, "FETCH_SUCCESS": "EPG تجلب بنجاح", "IMPORT_PROGRESS": "استيراد EPG", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 2e4d9528b..5bf494cac 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -792,7 +792,11 @@ "LANGUAGE": "Мова", "SHOW_PROGRAM_DETAILS": "Паказаць падрабязнасці гэтай перадачы", "EPISODE": "Эпізод", - "DURATION": "Працягласць" + "DURATION": "Працягласць", + "COPY_ARCHIVE_URL": "Капіяваць спасылку на архіў", + "ARCHIVE_URL_COPIED": "Спасылка на архіў скапіявана", + "ARCHIVE_URL_FAILED": "Не ўдалося скапіяваць спасылку на архіў", + "ARCHIVE_URL_NOTICE": "Спасылка можа змяшчаць даныя ўліковага запісу. Знешняму прайгравальніку таксама могуць спатрэбіцца HTTP-загалоўкі крыніцы." }, "FETCH_SUCCESS": "EPG быў паспяхова загружаны", "IMPORT_PROGRESS": "Імпарт EPG", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 7fb11035c..d71555cf6 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -792,7 +792,11 @@ "LANGUAGE": "Sprache", "SHOW_PROGRAM_DETAILS": "Details zu dieser Sendung anzeigen", "EPISODE": "Episode", - "DURATION": "Dauer" + "DURATION": "Dauer", + "COPY_ARCHIVE_URL": "Archiv-URL kopieren", + "ARCHIVE_URL_COPIED": "Archiv-URL kopiert", + "ARCHIVE_URL_FAILED": "Archiv-URL konnte nicht kopiert werden", + "ARCHIVE_URL_NOTICE": "Die URL kann Zugangsdaten enthalten. Externe Player benötigen möglicherweise auch die HTTP-Header der Quelle." }, "FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen", "IMPORT_PROGRESS": "EPG-Import", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dfbf61091..6488ac900 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -792,7 +792,11 @@ "LANGUAGE": "Γλώσσα", "SHOW_PROGRAM_DETAILS": "Εμφάνιση λεπτομερειών για αυτό το πρόγραμμα", "EPISODE": "Επεισόδιο", - "DURATION": "Διάρκεια" + "DURATION": "Διάρκεια", + "COPY_ARCHIVE_URL": "Αντιγραφή URL αρχείου", + "ARCHIVE_URL_COPIED": "Το URL αρχείου αντιγράφηκε", + "ARCHIVE_URL_FAILED": "Αδυναμία αντιγραφής του URL αρχείου", + "ARCHIVE_URL_NOTICE": "Το URL μπορεί να περιέχει στοιχεία σύνδεσης. Τα εξωτερικά προγράμματα αναπαραγωγής μπορεί να χρειάζονται και τις κεφαλίδες HTTP της πηγής." }, "FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία", "IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 9fe29476e..5a05b958c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -792,7 +792,11 @@ "LANGUAGE": "Language", "SHOW_PROGRAM_DETAILS": "Show details about this program", "EPISODE": "Episode", - "DURATION": "Duration" + "DURATION": "Duration", + "COPY_ARCHIVE_URL": "Copy archive URL", + "ARCHIVE_URL_COPIED": "Archive URL copied", + "ARCHIVE_URL_FAILED": "Could not copy the archive URL", + "ARCHIVE_URL_NOTICE": "The URL may contain account credentials. External players may also need your source’s HTTP headers." }, "FETCH_SUCCESS": "EPG was fetched successfully", "IMPORT_PROGRESS": "EPG Import", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index fc3c939e8..3e6c332c0 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -792,7 +792,11 @@ "LANGUAGE": "Idioma", "SHOW_PROGRAM_DETAILS": "Mostrar detalles de este programa", "EPISODE": "Episodio", - "DURATION": "Duración" + "DURATION": "Duración", + "COPY_ARCHIVE_URL": "Copiar URL del archivo", + "ARCHIVE_URL_COPIED": "URL del archivo copiada", + "ARCHIVE_URL_FAILED": "No se pudo copiar la URL del archivo", + "ARCHIVE_URL_NOTICE": "La URL puede contener credenciales. Los reproductores externos también pueden necesitar las cabeceras HTTP de la fuente." }, "FETCH_SUCCESS": "EPG obtenida correctamente", "IMPORT_PROGRESS": "Importación de EPG", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 8a3ac8fc4..aa709e6c9 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -792,7 +792,11 @@ "LANGUAGE": "Langue", "SHOW_PROGRAM_DETAILS": "Afficher les détails de ce programme", "EPISODE": "Épisode", - "DURATION": "Durée" + "DURATION": "Durée", + "COPY_ARCHIVE_URL": "Copier le lien de rattrapage", + "ARCHIVE_URL_COPIED": "Lien de rattrapage copié", + "ARCHIVE_URL_FAILED": "Impossible de copier le lien de rattrapage", + "ARCHIVE_URL_NOTICE": "Le lien peut contenir des identifiants. Les lecteurs externes peuvent aussi nécessiter les en-têtes HTTP de la source." }, "FETCH_SUCCESS": "L'EPG a été récupéré avec succès", "IMPORT_PROGRESS": "Importation EPG", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index b48fa91f3..7e68c74bc 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -792,7 +792,11 @@ "LANGUAGE": "Nyelv", "SHOW_PROGRAM_DETAILS": "Műsor részleteinek megjelenítése", "EPISODE": "Epizód", - "DURATION": "Időtartam" + "DURATION": "Időtartam", + "COPY_ARCHIVE_URL": "Archív URL másolása", + "ARCHIVE_URL_COPIED": "Archív URL másolva", + "ARCHIVE_URL_FAILED": "Az archív URL másolása sikertelen", + "ARCHIVE_URL_NOTICE": "Az URL bejelentkezési adatokat tartalmazhat. A külső lejátszóknak a forrás HTTP-fejléceire is szükségük lehet." }, "FETCH_SUCCESS": "Az EPG lekérése sikerült", "IMPORT_PROGRESS": "EPG importálása", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 1a1988627..cfa0294ff 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -792,7 +792,11 @@ "LANGUAGE": "Lingua", "SHOW_PROGRAM_DETAILS": "Mostra dettagli su questo programma", "EPISODE": "Episodio", - "DURATION": "Durata" + "DURATION": "Durata", + "COPY_ARCHIVE_URL": "Copia URL archivio", + "ARCHIVE_URL_COPIED": "URL archivio copiato", + "ARCHIVE_URL_FAILED": "Impossibile copiare URL archivio", + "ARCHIVE_URL_NOTICE": "L’URL può contenere credenziali. I lettori esterni potrebbero richiedere anche le intestazioni HTTP della sorgente." }, "FETCH_SUCCESS": "EPG scaricato correttamente", "IMPORT_PROGRESS": "Importazione EPG", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index a3c97d7ef..1059a8633 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -792,7 +792,11 @@ "LANGUAGE": "言語", "SHOW_PROGRAM_DETAILS": "この番組の詳細を表示", "EPISODE": "エピソード", - "DURATION": "長さ" + "DURATION": "長さ", + "COPY_ARCHIVE_URL": "アーカイブURLをコピー", + "ARCHIVE_URL_COPIED": "アーカイブURLをコピーしました", + "ARCHIVE_URL_FAILED": "アーカイブURLをコピーできませんでした", + "ARCHIVE_URL_NOTICE": "URLにはアカウントの認証情報が含まれる場合があります。外部プレーヤーではソースのHTTPヘッダーも必要になることがあります。" }, "FETCH_SUCCESS": "EPGの取得に成功しました", "IMPORT_PROGRESS": "EPGインポート", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 92b2b3187..5281dc48d 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -792,7 +792,11 @@ "LANGUAGE": "언어", "SHOW_PROGRAM_DETAILS": "이 프로그램의 상세 정보 표시", "EPISODE": "에피소드", - "DURATION": "재생 시간" + "DURATION": "재생 시간", + "COPY_ARCHIVE_URL": "다시보기 URL 복사", + "ARCHIVE_URL_COPIED": "다시보기 URL이 복사되었습니다", + "ARCHIVE_URL_FAILED": "다시보기 URL을 복사하지 못했습니다", + "ARCHIVE_URL_NOTICE": "URL에 계정 인증 정보가 포함될 수 있습니다. 외부 플레이어에는 소스의 HTTP 헤더도 필요할 수 있습니다." }, "FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다", "IMPORT_PROGRESS": "EPG 가져오기", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index db1b926a5..7b4ad4985 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -792,7 +792,11 @@ "LANGUAGE": "Taal", "SHOW_PROGRAM_DETAILS": "Details over dit programma tonen", "EPISODE": "Aflevering", - "DURATION": "Duur" + "DURATION": "Duur", + "COPY_ARCHIVE_URL": "Archief-URL kopiëren", + "ARCHIVE_URL_COPIED": "Archief-URL gekopieerd", + "ARCHIVE_URL_FAILED": "Kan archief-URL niet kopiëren", + "ARCHIVE_URL_NOTICE": "De URL kan inloggegevens bevatten. Externe spelers hebben mogelijk ook de HTTP-headers van de bron nodig." }, "FETCH_SUCCESS": "TV-Gids bijgewerkt", "IMPORT_PROGRESS": "EPG-import", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index aa82867ff..56bc70077 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -792,7 +792,11 @@ "LANGUAGE": "Język", "SHOW_PROGRAM_DETAILS": "Pokaż szczegóły tego programu", "EPISODE": "Odcinek", - "DURATION": "Czas trwania" + "DURATION": "Czas trwania", + "COPY_ARCHIVE_URL": "Kopiuj adres archiwum", + "ARCHIVE_URL_COPIED": "Skopiowano adres archiwum", + "ARCHIVE_URL_FAILED": "Nie udało się skopiować adresu archiwum", + "ARCHIVE_URL_NOTICE": "Adres może zawierać dane logowania. Odtwarzacze zewnętrzne mogą też wymagać nagłówków HTTP źródła." }, "FETCH_SUCCESS": "EPG zostało pomyślnie pobrane", "IMPORT_PROGRESS": "Import EPG", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 052767f10..b79a001d9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -792,7 +792,11 @@ "LANGUAGE": "Idioma", "SHOW_PROGRAM_DETAILS": "Mostrar detalhes sobre este programa", "EPISODE": "Episódio", - "DURATION": "Duração" + "DURATION": "Duração", + "COPY_ARCHIVE_URL": "Copiar URL do arquivo", + "ARCHIVE_URL_COPIED": "URL do arquivo copiada", + "ARCHIVE_URL_FAILED": "Não foi possível copiar a URL do arquivo", + "ARCHIVE_URL_NOTICE": "A URL pode conter credenciais. Os leitores externos também podem precisar dos cabeçalhos HTTP da fonte." }, "FETCH_SUCCESS": "EPG foi buscado com sucesso", "IMPORT_PROGRESS": "Importação de EPG", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 2e7ed9474..902dff313 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -792,7 +792,11 @@ "LANGUAGE": "Язык", "SHOW_PROGRAM_DETAILS": "Показать сведения об этой программе", "EPISODE": "Эпизод", - "DURATION": "Длительность" + "DURATION": "Длительность", + "COPY_ARCHIVE_URL": "Копировать ссылку на архив", + "ARCHIVE_URL_COPIED": "Ссылка на архив скопирована", + "ARCHIVE_URL_FAILED": "Не удалось скопировать ссылку на архив", + "ARCHIVE_URL_NOTICE": "Ссылка может содержать данные аккаунта. Внешнему плееру также могут понадобиться HTTP-заголовки источника." }, "FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена", "IMPORT_PROGRESS": "Импорт EPG", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7327e33d4..3fc3cf1e9 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -792,7 +792,11 @@ "LANGUAGE": "Dil", "SHOW_PROGRAM_DETAILS": "Bu programın ayrıntılarını göster", "EPISODE": "Bölüm", - "DURATION": "Süre" + "DURATION": "Süre", + "COPY_ARCHIVE_URL": "Arşiv URL’sini kopyala", + "ARCHIVE_URL_COPIED": "Arşiv URL’si kopyalandı", + "ARCHIVE_URL_FAILED": "Arşiv URL’si kopyalanamadı", + "ARCHIVE_URL_NOTICE": "URL hesap bilgileri içerebilir. Harici oynatıcılar kaynağın HTTP başlıklarına da ihtiyaç duyabilir." }, "FETCH_SUCCESS": "EPG verileri başarıyla alındı", "IMPORT_PROGRESS": "EPG İçe Aktarımı", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index aa80b4476..9072d98a4 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -792,7 +792,11 @@ "LANGUAGE": "语言", "SHOW_PROGRAM_DETAILS": "显示此节目的详细信息", "EPISODE": "剧集", - "DURATION": "时长" + "DURATION": "时长", + "COPY_ARCHIVE_URL": "复制回看链接", + "ARCHIVE_URL_COPIED": "已复制回看链接", + "ARCHIVE_URL_FAILED": "无法复制回看链接", + "ARCHIVE_URL_NOTICE": "链接可能包含账户凭据。外部播放器可能还需要此来源的 HTTP 请求头。" }, "FETCH_SUCCESS": "EPG 获取成功", "IMPORT_PROGRESS": "EPG 导入", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index a59d01238..9af8fab53 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -792,7 +792,11 @@ "LANGUAGE": "語言", "SHOW_PROGRAM_DETAILS": "顯示關於該節目的詳細資訊", "EPISODE": "集數", - "DURATION": "時長" + "DURATION": "時長", + "COPY_ARCHIVE_URL": "複製回看連結", + "ARCHIVE_URL_COPIED": "已複製回看連結", + "ARCHIVE_URL_FAILED": "無法複製回看連結", + "ARCHIVE_URL_NOTICE": "連結可能包含帳戶憑證。外部播放器可能還需要此來源的 HTTP 標頭。" }, "FETCH_SUCCESS": "EPG 已成功擷取", "IMPORT_PROGRESS": "EPG 匯入", diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 39103f441..6fb2d449a 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1153,6 +1153,24 @@ class EpgService { for a programme the user clicked, both hosts surface a `EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing. +### Copy archive URL + +Programme details opened from the EPG timeline or list offer **Copy archive +URL** when catch-up is available. Xtream Live TV, M3U playback and their +Favorites/Recent live tabs resolve the same URL as archive playback without +selecting a programme or changing the player. Stalker and unsupported/expired +archive entries expose no copy action. The full M3U guide currently has no +catch-up capability and therefore does not expose this action. + +The URL can contain account credentials; the dialog explains that external +players may also need the source's HTTP headers. Copy is explicit, errors use +localized feedback without raw URLs, and each explicit request supersedes older +unresolved copies. Only the latest request may update the clipboard or feedback, +so a slow resolver cannot block another channel or overwrite its copied URL. Timeline/list dialogs capture the host's source/channel +`archiveContextKey`, refuse actions after it changes, and unsubscribe when +the view is destroyed. A copied M3U start-over URL need not stop at the programme's +end. Copying a URL does not download or preserve an expiring archive. + ### Programme guide `app-epg-guide` (`libs/ui/epg/src/lib/epg-guide/`) is a host-agnostic diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 25d6b02ef..6e89fb883 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -257,6 +257,11 @@ tab can gate the timeline's archive window. `tv_archive_duration` is interpreted as **days** everywhere, matching `live-stream-layout.controlledArchiveDays` (issue #1138). +Programme details in the Live TV and Favorites/Recent EPG timeline/list also +offer **Copy archive URL**. This uses the same resolver and persisted server +timezone without changing playback. See the M3U module's "Copy archive URL" +contract for clipboard feedback and credential/header handling. + ### Start time is the panel's clock, not the viewer's The `{start}` segment (`Y-m-d:H-M`) is read by the panel with `strtotime()` diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts index 68e546a0c..8cab98b70 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts @@ -1,3 +1,4 @@ +import { Clipboard } from '@angular/cdk/clipboard'; import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; @@ -17,6 +18,8 @@ import { import { Settings, VideoPlayer } from '@iptvnator/shared/interfaces'; import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component'; import { + activeChannel, + buildAiringProgram, dataServiceMock, epgServiceMock, epgUrlSetting, @@ -229,4 +232,27 @@ describe('VideoPlayerComponent — guide gating', () => { expect(component.guideOpen()).toBe(true); overlayContainer.remove(); }); + it('copies the archive URL without dispatching playback or opening the guide', async () => { + const copy = jest + .spyOn(TestBed.inject(Clipboard), 'copy') + .mockReturnValue(true); + activeChannel.set({ + ...sampleChannel, + catchup: { type: 'shift', days: '7' }, + }); + const program = buildAiringProgram('Archive'); + fixture.detectChanges(); + await fixture.whenStable(); + storeMock.dispatch.mockClear(); + component.onTimelineProgramActivated({ + type: 'copy-catchup-url', + program, + }); + await fixture.whenStable(); + expect(copy).toHaveBeenCalledWith( + expect.stringContaining(`utc=${program.startTimestamp}`) + ); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + copy.mockRestore(); + }); }); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 5974fd0bc..a6c3cf737 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -175,6 +175,7 @@ [offsetMinutes]="epgOffsetMinutes()" [channelName]="timelineChannelName()" [channelLogo]="timelineChannelLogo()" + [archiveContextKey]="archiveContextKey()" [archivePlaybackAvailable]=" archivePlaybackAvailable() " @@ -211,6 +212,7 @@ [offsetMinutes]="epgOffsetMinutes()" [channelName]="timelineChannelName()" [channelLogo]="timelineChannelLogo()" + [archiveContextKey]="archiveContextKey()" [archivePlaybackAvailable]=" archivePlaybackAvailable() " diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 0d2a26bb0..cd9e83b1c 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -32,9 +32,11 @@ import { isDashStreamUrl, isLikelyM3uMovie, isM3uCatchupPlaybackSupported, + resolveM3uCatchupUrl, } from '@iptvnator/shared/m3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { + EpgArchiveCopyService, EPG_GUIDE_SOURCE, EpgDateNavigationDirection, EpgGuideComponent, @@ -307,6 +309,14 @@ export class VideoPlayerComponent readonly fullscreenPanelChannels = computed(() => this.channels().filter((channel) => this.keepsInlinePlayer(channel)) ); + readonly archiveContextKey = computed(() => + JSON.stringify([ + this.activePlaylistId(), + this.activeChannel()?.id, + this.activeChannel()?.url, + ]) + ); + private readonly archiveCopy = inject(EpgArchiveCopyService); private readonly guideSource = inject(M3uEpgGuideSourceService); /** Guide mode: sidebar and timeline give way to the multi-channel grid. */ readonly guideOpen = signal(false); @@ -1108,6 +1118,13 @@ export class VideoPlayerComponent } onTimelineProgramActivated(event: EpgProgramActivationEvent): void { + if (event.type === 'copy-catchup-url') { + const channel = this.activeChannel(); + void this.archiveCopy.copy(() => + resolveM3uCatchupUrl(channel, event.program) + ); + return; + } if (event.type === 'live') { this.returnToLivePlayback(); return; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts index 8140190ac..607e35535 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts @@ -26,6 +26,7 @@ export class StubEpgTimelineComponent { readonly channelName = input(''); readonly channelLogo = input(''); readonly archivePlaybackAvailable = input(false); + readonly archiveContextKey = input(null); readonly archiveDays = input(0); readonly activeProgram = input(null); readonly isLivePlayback = input(true); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 23b486f85..a205255c3 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -90,6 +90,7 @@ [offsetMinutes]="epgOffsetMinutes()" [channelName]="timelineChannelName()" [channelLogo]="timelineChannelLogo()" + [archiveContextKey]="archiveContextKey()" [archivePlaybackAvailable]=" timelineArchiveAvailable() " @@ -120,6 +121,7 @@ [offsetMinutes]="epgOffsetMinutes()" [channelName]="timelineChannelName()" [channelLogo]="timelineChannelLogo()" + [archiveContextKey]="archiveContextKey()" [archivePlaybackAvailable]=" timelineArchiveAvailable() " diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 289047435..1ef25911a 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -1,3 +1,12 @@ +import { + buildLiveItem, + buildProgram, + buildCurrentProgram, + buildEpgItem, + buildCurrentEpgItem, + createDeferred, +} from './unified-live-tab.spec-data'; +import { Clipboard } from '@angular/cdk/clipboard'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -18,11 +27,7 @@ import { import { MatSnackBar } from '@angular/material/snack-bar'; import { ResizableDirective } from '@iptvnator/ui/components'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; -import { - EpgItem, - EpgProgram, - VideoPlayer, -} from '@iptvnator/shared/interfaces'; +import { EpgItem, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces'; import { PORTAL_PLAYER, UnifiedCollectionItem, @@ -983,6 +988,25 @@ describe('UnifiedLiveTabComponent', () => { type: 'timeshift', }); + it('copies M3U archive URLs without changing the active playback', async () => { + await selectCatchupChannel(); + const before = component.inlinePlayback(); + const copy = jest + .spyOn(TestBed.inject(Clipboard), 'copy') + .mockReturnValue(true); + component.onTimelineProgramActivated({ + ...timeshiftEvent(), + type: 'copy-catchup-url', + }); + await fixture.whenStable(); + expect(copy).toHaveBeenCalledWith( + expect.stringContaining('https://example.com/m3u.m3u8?utc=') + ); + expect(component.inlinePlayback()).toBe(before); + expect(component.activeTimeshift()).toBeNull(); + copy.mockRestore(); + }); + it('switches the inline player to the catch-up stream on Watch', async () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(true); await selectCatchupChannel(); @@ -1120,6 +1144,28 @@ describe('UnifiedLiveTabComponent', () => { fixture.debugElement.query(By.directive(StubEpgTimelineComponent)) .componentInstance as StubEpgTimelineComponent; + it('copies Xtream archive URLs without changing the active playback', async () => { + streamResolver.resolveXtreamCatchupUrl.mockResolvedValue( + 'https://example.com/timeshift.ts' + ); + await selectXtreamArchiveChannel(); + const before = component.inlinePlayback(); + const copy = jest + .spyOn(TestBed.inject(Clipboard), 'copy') + .mockReturnValue(true); + component.onTimelineProgramActivated({ + ...timeshiftEvent(), + type: 'copy-catchup-url', + }); + await fixture.whenStable(); + expect(copy).toHaveBeenCalledWith( + 'https://example.com/timeshift.ts' + ); + expect(component.inlinePlayback()).toBe(before); + expect(component.activeTimeshift()).toBeNull(); + copy.mockRestore(); + }); + it('exposes the provider archive window to the timeline in days', async () => { // Regression: tv_archive_duration is days (matching // live-stream-layout.controlledArchiveDays), not hours — a @@ -1215,120 +1261,3 @@ describe('UnifiedLiveTabComponent', () => { }); }); }); - -function buildLiveItem( - sourceType: 'm3u' | 'xtream' | 'stalker' -): UnifiedCollectionItem { - if (sourceType === 'm3u') { - return { - uid: 'm3u::pl-1::m3u-channel', - name: 'M3U Live', - contentType: 'live', - sourceType: 'm3u', - playlistId: 'pl-1', - playlistName: 'Playlist One', - streamUrl: 'https://example.com/m3u.m3u8', - channelId: 'm3u-channel', - tvgId: 'm3u-channel', - logo: 'm3u.png', - radio: 'false', - }; - } - - if (sourceType === 'xtream') { - return { - uid: 'xtream::pl-2::20', - name: 'Xtream Live', - contentType: 'live', - sourceType: 'xtream', - playlistId: 'pl-2', - playlistName: 'Playlist Two', - xtreamId: 20, - tvgId: '20', - logo: 'xtream.png', - }; - } - - return { - uid: 'stalker::pl-3::30', - name: 'Stalker Live', - contentType: 'live', - sourceType: 'stalker', - playlistId: 'pl-3', - playlistName: 'Playlist Three', - stalkerId: '30', - stalkerCmd: 'ffmpeg http://stalker/30', - tvgId: '30', - logo: 'stalker.png', - }; -} - -function buildProgram(title: string): EpgProgram { - return { - start: '2026-03-26T11:00:00.000Z', - stop: '2026-03-26T12:00:00.000Z', - channel: 'test-channel', - title, - desc: `${title} description`, - category: null, - }; -} - -function buildCurrentProgram(title: string): EpgProgram { - const now = Date.now(); - return { - start: new Date(now - 10 * 60 * 1000).toISOString(), - stop: new Date(now + 10 * 60 * 1000).toISOString(), - channel: 'test-channel', - title, - desc: `${title} description`, - category: null, - }; -} - -function buildEpgItem(title: string): EpgItem { - return { - id: '1', - epg_id: '', - title, - description: `${title} description`, - lang: '', - start: '2026-03-26T11:00:00.000Z', - end: '2026-03-26T12:00:00.000Z', - stop: '2026-03-26T12:00:00.000Z', - channel_id: '1', - start_timestamp: '1774522800', - stop_timestamp: '1774526400', - }; -} - -function buildCurrentEpgItem(title: string): EpgItem { - const now = Date.now(); - const start = now - 10 * 60 * 1000; - const stop = now + 10 * 60 * 1000; - - return { - id: '1', - epg_id: '', - title, - description: `${title} description`, - lang: '', - start: new Date(start).toISOString(), - end: new Date(stop).toISOString(), - stop: new Date(stop).toISOString(), - channel_id: '1', - start_timestamp: String(Math.floor(start / 1000)), - stop_timestamp: String(Math.floor(stop / 1000)), - }; -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 7a20a4edc..821bc1b3c 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -44,6 +44,7 @@ import { UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; import { + EpgArchiveCopyService, EpgDateNavigationDirection, EpgListViewComponent, EpgProgramActivationEvent, @@ -135,6 +136,14 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { /** The rail is owned by the page header toggle; ask it to expand. */ readonly restoreSidebarRequested = output(); + readonly archiveContextKey = computed(() => + JSON.stringify([ + this.activeItem()?.uid, + this.activeItem()?.playlistId, + this.currentM3uChannel()?.url, + ]) + ); + private readonly archiveCopy = inject(EpgArchiveCopyService); private readonly streamResolver = inject(StreamResolverService); private readonly recentData = inject(UnifiedRecentDataService); private readonly runtime = inject(RuntimeCapabilitiesService); @@ -644,6 +653,33 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } onTimelineProgramActivated(event: EpgProgramActivationEvent): void { + if (event.type === 'copy-catchup-url') { + const channel = this.currentM3uChannel(); + const item = this.activeItem(); + const isM3u = this.isM3uSelection(); + void this.archiveCopy.copy(() => { + if (isM3u) return resolveM3uCatchupUrl(channel, event.program); + const start = toEpochSeconds( + event.program.startTimestamp, + event.program.start + ); + const stop = toEpochSeconds( + event.program.stopTimestamp, + event.program.stop + ); + return item?.xtreamId && + start != null && + stop != null && + stop > start + ? this.streamResolver.resolveXtreamCatchupUrl( + item, + start, + stop + ) + : null; + }); + return; + } if (event.type === 'live') { this.returnToLivePlayback(); return; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-data.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-data.ts new file mode 100644 index 000000000..f4710566b --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-data.ts @@ -0,0 +1,119 @@ +import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; + +export function buildLiveItem( + sourceType: 'm3u' | 'xtream' | 'stalker' +): UnifiedCollectionItem { + if (sourceType === 'm3u') { + return { + uid: 'm3u::pl-1::m3u-channel', + name: 'M3U Live', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'pl-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/m3u.m3u8', + channelId: 'm3u-channel', + tvgId: 'm3u-channel', + logo: 'm3u.png', + radio: 'false', + }; + } + + if (sourceType === 'xtream') { + return { + uid: 'xtream::pl-2::20', + name: 'Xtream Live', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'pl-2', + playlistName: 'Playlist Two', + xtreamId: 20, + tvgId: '20', + logo: 'xtream.png', + }; + } + + return { + uid: 'stalker::pl-3::30', + name: 'Stalker Live', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'pl-3', + playlistName: 'Playlist Three', + stalkerId: '30', + stalkerCmd: 'ffmpeg http://stalker/30', + tvgId: '30', + logo: 'stalker.png', + }; +} + +export function buildProgram(title: string): EpgProgram { + return { + start: '2026-03-26T11:00:00.000Z', + stop: '2026-03-26T12:00:00.000Z', + channel: 'test-channel', + title, + desc: `${title} description`, + category: null, + }; +} + +export function buildCurrentProgram(title: string): EpgProgram { + const now = Date.now(); + return { + start: new Date(now - 10 * 60 * 1000).toISOString(), + stop: new Date(now + 10 * 60 * 1000).toISOString(), + channel: 'test-channel', + title, + desc: `${title} description`, + category: null, + }; +} + +export function buildEpgItem(title: string): EpgItem { + return { + id: '1', + epg_id: '', + title, + description: `${title} description`, + lang: '', + start: '2026-03-26T11:00:00.000Z', + end: '2026-03-26T12:00:00.000Z', + stop: '2026-03-26T12:00:00.000Z', + channel_id: '1', + start_timestamp: '1774522800', + stop_timestamp: '1774526400', + }; +} + +export function buildCurrentEpgItem(title: string): EpgItem { + const now = Date.now(); + const start = now - 10 * 60 * 1000; + const stop = now + 10 * 60 * 1000; + + return { + id: '1', + epg_id: '', + title, + description: `${title} description`, + lang: '', + start: new Date(start).toISOString(), + end: new Date(stop).toISOString(), + stop: new Date(stop).toISOString(), + channel_id: '1', + start_timestamp: String(Math.floor(start / 1000)), + stop_timestamp: String(Math.floor(stop / 1000)), + }; +} + +export function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts index 995a5fe15..f6ce4fdbc 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -73,6 +73,7 @@ export class StubEpgTimelineComponent { readonly channelName = input(''); readonly channelLogo = input(''); readonly archivePlaybackAvailable = input(false); + readonly archiveContextKey = input(null); readonly archiveDays = input(0); readonly activeProgram = input(null); readonly isLivePlayback = input(true); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts index 18c0d89dd..03a7ccff8 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts @@ -1,3 +1,4 @@ +import { EpgArchiveCopyService } from '@iptvnator/ui/epg'; import { computed, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { @@ -89,6 +90,10 @@ describe('Xtream live auto-open playback queue', () => { await TestBed.configureTestingModule({ imports: [LiveStreamLayoutComponent], providers: [ + { + provide: EpgArchiveCopyService, + useValue: { copy: jest.fn() }, + }, { provide: XtreamStore, useValue: store }, { provide: ActivatedRoute, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts index a64f26c32..e41321657 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts @@ -73,6 +73,7 @@ export class StubEpgTimelineComponent { readonly channelLogo = input(''); readonly sourceLabel = input(''); readonly archivePlaybackAvailable = input(false); + readonly archiveContextKey = input(null); readonly archiveDays = input(0); readonly activeProgram = input(null); readonly isLivePlayback = input(true); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 1bd7b99d8..2152731e5 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -229,6 +229,7 @@ [channelName]="activePlayback()?.title ?? ''" [channelLogo]="activePlayback()?.thumbnail ?? ''" [sourceLabel]="'Xtream'" + [archiveContextKey]="archiveContextKey()" [archivePlaybackAvailable]="archivePlaybackAvailable()" [archiveDays]="controlledArchiveDays()" [activeProgram]="activeCatchupProgram()" @@ -250,6 +251,7 @@ [channelName]="activePlayback()?.title ?? ''" [channelLogo]="activePlayback()?.thumbnail ?? ''" [sourceLabel]="'Xtream'" + [archiveContextKey]="archiveContextKey()" [archivePlaybackAvailable]="archivePlaybackAvailable()" [archiveDays]="controlledArchiveDays()" [activeProgram]="activeCatchupProgram()" diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 634130699..858ef83b0 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -24,6 +24,7 @@ import { XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; import { + EpgArchiveCopyService, EpgListViewComponent, EpgTimelineComponent, } from '@iptvnator/ui/epg'; @@ -185,6 +186,16 @@ describe('LiveStreamLayoutComponent', () => { await TestBed.configureTestingModule({ imports: [LiveStreamLayoutComponent, NoopAnimationsModule], providers: [ + { + provide: EpgArchiveCopyService, + useValue: { + copy: jest.fn( + async (resolve: () => Promise) => { + await resolve(); + } + ), + }, + }, { provide: ActivatedRoute, useValue: { @@ -237,6 +248,7 @@ describe('LiveStreamLayoutComponent', () => { .overrideComponent(LiveStreamLayoutComponent, { remove: { imports: [ + EpgArchiveCopyService, EpgListViewComponent, EpgTimelineComponent, GridListComponent, @@ -265,11 +277,17 @@ describe('LiveStreamLayoutComponent', () => { fixture = TestBed.createComponent(LiveStreamLayoutComponent); component = fixture.componentInstance; - TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded'); + TestBed.inject(LiveLayoutSidebarStateService).setState( + 'portal', + 'expanded' + ); }); afterEach(() => { - TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded'); + TestBed.inject(LiveLayoutSidebarStateService).setState( + 'portal', + 'expanded' + ); fixture.destroy(); jest.useRealTimers(); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); @@ -767,6 +785,35 @@ describe('LiveStreamLayoutComponent', () => { ); }); + it('copies the programme URL without changing playback or channel selection', async () => { + const before = component.activePlayback(); + xtreamStore.openPlayer.mockClear(); + xtreamStore.setSelectedItem.mockClear(); + await component.onProgramActivated({ + type: 'copy-catchup-url', + program: { + start: '2026-04-04T10:00:00Z', + stop: '2026-04-04T11:00:00Z', + channel: 'channel-101', + title: 'Archive', + desc: null, + category: null, + }, + }); + expect(TestBed.inject(EpgArchiveCopyService).copy).toHaveBeenCalled(); + expect(xtreamUrlService.resolveCatchupUrl).toHaveBeenCalledWith( + playlist.id, + expect.objectContaining({ serverUrl: playlist.serverUrl }), + 101, + 1775296800, + 1775300400, + undefined + ); + expect(component.activePlayback()).toBe(before); + expect(xtreamStore.openPlayer).not.toHaveBeenCalled(); + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + }); + it('does not attach a pending catchup result to a newer live owner', async () => { let resolveCatchup!: (url: string) => void; xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce( diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 86f6f1345..6c73b284a 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -54,6 +54,7 @@ import { XtreamStore, } from '@iptvnator/portal/xtream/data-access'; import { + EpgArchiveCopyService, EpgDateNavigationDirection, EpgListViewComponent, EpgProgramActivationEvent, @@ -145,6 +146,14 @@ export class LiveStreamLayoutComponent private readonly router = inject(Router); private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); + readonly archiveContextKey = computed(() => + JSON.stringify([ + this.xtreamStore.currentPlaylist()?.id, + this.xtreamStore.currentPlaylist()?.serverUrl, + this.selectedLiveItem()?.xtream_id, + ]) + ); + private readonly archiveCopy = inject(EpgArchiveCopyService); private readonly xtreamUrlService = inject(XtreamUrlService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); @@ -610,6 +619,30 @@ export class LiveStreamLayoutComponent return; } + if (event.type === 'copy-catchup-url') { + const playlist = this.xtreamStore.currentPlaylist(); + await this.archiveCopy.copy(() => { + const start = this.getProgramTimestampSeconds( + event.program.start, + event.program.startTimestamp + ); + const stop = this.getProgramTimestampSeconds( + event.program.stop, + event.program.stopTimestamp + ); + return playlist && start && stop && stop > start + ? this.xtreamUrlService.resolveCatchupUrl( + playlist.id, + playlist, + selectedItem.xtream_id, + start, + stop, + playlist.serverTimezone + ) + : null; + }); + return; + } if (event.type === 'live') { this.playLive(selectedItem, true); return; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts index e7d38b71d..202f80e2a 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts @@ -1,3 +1,4 @@ +import { EpgArchiveCopyService } from '@iptvnator/ui/epg'; import { Component, Directive, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -130,6 +131,10 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { await TestBed.configureTestingModule({ imports: [LiveStreamLayoutComponent, NoopAnimationsModule], providers: [ + { + provide: EpgArchiveCopyService, + useValue: { copy: jest.fn() }, + }, { provide: ActivatedRoute, useValue: { @@ -182,7 +187,10 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { openExternalPlayback: jest.fn(), }, }, - { provide: LIVE_CATEGORIES_POPOVER, useValue: categoriesPopover }, + { + provide: LIVE_CATEGORIES_POPOVER, + useValue: categoriesPopover, + }, ], }) .overrideComponent(LiveStreamLayoutComponent, { diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index 4bdb5bff2..caaadd268 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -28,3 +28,5 @@ export * from './lib/epg-guide/epg-guide-layout.util'; export * from './lib/epg-guide/epg-guide-preferences'; export * from './lib/epg-guide/epg-guide.component'; export * from './lib/epg-guide/epg-guide-now-playing.component'; + +export * from './lib/epg-archive-copy.service'; diff --git a/libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts b/libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts new file mode 100644 index 000000000..3b7964e62 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts @@ -0,0 +1,105 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { EpgArchiveCopyService } from './epg-archive-copy.service'; + +describe('EpgArchiveCopyService', () => { + const copy = jest.fn(); + const open = jest.fn(); + let service: EpgArchiveCopyService; + beforeEach(() => { + copy.mockReset().mockReturnValue(true); + open.mockReset(); + TestBed.configureTestingModule({ + providers: [ + { provide: Clipboard, useValue: { copy } }, + { provide: MatSnackBar, useValue: { open } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + service = TestBed.inject(EpgArchiveCopyService); + }); + it('copies the resolved URL and reports success', async () => { + await service.copy(() => + Promise.resolve('https://provider.test/archive.ts') + ); + expect(copy).toHaveBeenCalledWith('https://provider.test/archive.ts'); + expect(open).toHaveBeenCalledWith( + 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_COPIED', + undefined, + { duration: 4000 } + ); + }); + it.each([null, '', 'javascript:alert(1)'])( + 'rejects an unavailable or unsafe URL: %s', + async (url) => { + await service.copy(() => url); + expect(copy).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith( + 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED', + undefined, + { duration: 4000 } + ); + } + ); + it('handles resolver and clipboard failures without exposing their messages', async () => { + await service.copy(() => { + throw new Error('https://user:secret@provider.test'); + }); + copy.mockReturnValue(false); + await service.copy(() => 'https://provider.test/archive.ts'); + expect(open.mock.calls.map(([key]) => key)).toEqual([ + 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED', + 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED', + ]); + }); + it.each([false, true])( + 'lets a new copy replace a slow request (old failure: %s)', + async (failOld) => { + let finish!: (url: string) => void; + let reject!: (error: Error) => void; + const first = service.copy( + () => + new Promise((resolve, fail) => { + finish = resolve; + reject = fail; + }) + ); + const latest = jest.fn(async () => 'https://provider.test/new.ts'); + await service.copy(latest); + expect(latest).toHaveBeenCalledTimes(1); + if (failOld) reject(new Error('secret old request')); + else finish('https://provider.test/old.ts'); + await first; + expect(copy.mock.calls).toEqual([['https://provider.test/new.ts']]); + expect(open).toHaveBeenCalledTimes(1); + } + ); + it('discards an old result even while the newer request is still pending', async () => { + let finishOld!: (url: string) => void, + finishNew!: (url: string) => void; + const first = service.copy( + () => + new Promise((resolve) => { + finishOld = resolve; + }) + ); + const second = service.copy( + () => + new Promise((resolve) => { + finishNew = resolve; + }) + ); + finishOld('https://provider.test/old.ts'); + await first; + expect(copy).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + finishNew('https://provider.test/new.ts'); + await second; + expect(copy).toHaveBeenCalledWith('https://provider.test/new.ts'); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-archive-copy.service.ts b/libs/ui/epg/src/lib/epg-archive-copy.service.ts new file mode 100644 index 000000000..5fe0274a9 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-archive-copy.service.ts @@ -0,0 +1,39 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { inject, Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; + +/** Clipboard feedback only; the host owns source identity and URL resolution. */ +@Injectable({ providedIn: 'root' }) +export class EpgArchiveCopyService { + private readonly clipboard = inject(Clipboard); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + private requestGeneration = 0; + + async copy( + resolve: () => string | null | Promise + ): Promise { + const generation = ++this.requestGeneration; + let copied = false; + try { + const url = await resolve(); + if (generation !== this.requestGeneration) return; + if (url && ['http:', 'https:'].includes(new URL(url).protocol)) { + copied = this.clipboard.copy(url); + } + } catch { + // Provider URLs and error messages may contain credentials. + } + if (generation !== this.requestGeneration) return; + this.snackBar.open( + this.translate.instant( + copied + ? 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_COPIED' + : 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED' + ), + undefined, + { duration: 4000 } + ); + } +} diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.html b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.html index c96db811a..acaa010ad 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.html +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.html @@ -29,7 +29,9 @@
- {{ startMs | date: 'EEE d MMM' : '' : currentLocale() }} + {{ + startMs | date: 'EEE d MMM' : '' : currentLocale() + }} {{ startMs | date: 'HH:mm' }} – @@ -73,6 +75,11 @@
} + @if (dialogData.archiveUrlAvailable) { +

+ {{ 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' | translate }} +

+ }
@if (primaryAction === 'live') { + } @if (archiveUnavailableNote) { {{ 'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss index b65314b1c..29dc39af4 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss @@ -181,6 +181,7 @@ /* ── actions ── */ .epg-dialog__actions { + flex-wrap: wrap; display: flex; align-items: center; gap: 10px; diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts index 9139aa1a7..ce7dd3c79 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts @@ -71,6 +71,28 @@ describe('EpgItemDescriptionComponent', () => { ); }); + it('offers archive URL copying only with explicit capability and explains credentials', () => { + Object.assign(component.dialogData, { archiveUrlAvailable: true }); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector( + '[data-testid="copy-catchup-url"]' + ) + ).toBeTruthy(); + expect(fixture.nativeElement.textContent).toContain( + 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' + ); + Object.assign(component.dialogData, { archiveUrlAvailable: false }); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector( + '[data-testid="copy-catchup-url"]' + ) + ).toBeNull(); + }); + it('applies the stored EPG display offset to the dialog timestamps', () => { const start = Date.parse('2026-04-05T11:30:00.000Z'); const stop = Date.parse('2026-04-05T12:30:00.000Z'); diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts index a6e024588..920c92418 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts @@ -11,9 +11,11 @@ import { EpgProgram } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { getProgramTimeMs } from '../epg-program.utils'; -export type EpgItemDialogAction = 'live' | 'timeshift'; +export type EpgItemDialogAction = 'live' | 'timeshift' | 'copy-catchup-url'; export type EpgItemDialogData = EpgProgram & { + /** Host can resolve an archive URL without starting playback. */ + archiveUrlAvailable?: boolean; channelName?: string | null; channel_name?: string | null; display_name?: string | null; @@ -64,10 +66,11 @@ export class EpgItemDescriptionComponent { constructor() { this.epgProgram = this.dialogData; // Check multiple possible field names for channel name - this.channelName = this.dialogData.channelName - || this.dialogData.channel_name - || this.dialogData.display_name - || null; + this.channelName = + this.dialogData.channelName || + this.dialogData.channel_name || + this.dialogData.display_name || + null; // Prefer the channel logo; fall back to the programme/EPG icon // (M3U playlists without tvg-logo still get an icon from the EPG feed). this.channelLogo = @@ -105,7 +108,9 @@ export class EpgItemDescriptionComponent { } const hours = Math.floor(mins / 60); const remainingMins = mins % 60; - return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`; + return remainingMins > 0 + ? `${hours}h ${remainingMins}m` + : `${hours}h`; } catch { return null; } diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts index d4ef19822..9b7b7eb83 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts @@ -32,12 +32,14 @@ function localDateKey(iso: string): string { describe('EpgListViewComponent', () => { let fixture: ComponentFixture; let component: EpgListViewComponent; - let dialogResult: BehaviorSubject<'live' | 'timeshift' | undefined>; + let dialogResult: BehaviorSubject< + 'live' | 'timeshift' | 'copy-catchup-url' | undefined + >; beforeEach(() => { - dialogResult = new BehaviorSubject<'live' | 'timeshift' | undefined>( - undefined - ); + dialogResult = new BehaviorSubject< + 'live' | 'timeshift' | 'copy-catchup-url' | undefined + >(undefined); TestBed.configureTestingModule({ imports: [EpgListViewComponent], providers: [ @@ -218,6 +220,48 @@ describe('EpgListViewComponent', () => { expect(events).toEqual(['timeshift']); }); + it('copies an archive URL without selecting or activating playback', () => { + const past = programAt(-180, 60, 'Earlier'); + setInputs({ + programs: [past], + selectedDate: localDateKey(past.start), + archivePlaybackAvailable: true, + archiveDays: 7, + }); + dialogResult.next('copy-catchup-url'); + const events: string[] = []; + component.programActivated.subscribe((event) => + events.push(event.type) + ); + const selection = component.selectedKey(); + component.openDetails(rowWhen('past')); + expect(events).toEqual(['copy-catchup-url']); + expect(component.selectedKey()).toBe(selection); + }); + + it('does not copy a programme from a dialog belonging to the previous channel', () => { + const past = programAt(-180, 60, 'Earlier'); + setInputs({ + programs: [past], + selectedDate: localDateKey(past.start), + archivePlaybackAvailable: true, + archiveDays: 7, + archiveContextKey: 'source:channel-a', + }); + const result = new BehaviorSubject(undefined); + jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({ + afterClosed: () => result, + } as never); + const events: string[] = []; + component.programActivated.subscribe((event) => + events.push(event.type) + ); + component.openDetails(rowWhen('past')); + setInputs({ archiveContextKey: 'source:channel-b' }); + result.next('copy-catchup-url'); + expect(events).toEqual([]); + }); + it('emits the day when stepping forward', () => { setInputs({ programs: [programAt(-30, 60)] }); const emitted: string[] = []; diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts index ea409642f..306d4fe2b 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts @@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, + DestroyRef, computed, ElementRef, inject, @@ -11,7 +12,7 @@ import { signal, viewChild, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { normalizeDateLocale } from '@iptvnator/pipes'; @@ -81,6 +82,8 @@ export class EpgListViewComponent { * timeline's literal "Timeline" while in list mode. */ readonly sourceLabel = input('Programme guide'); readonly archivePlaybackAvailable = input(false); + /** Source/channel identity captured by programme details, independent of playback. */ + readonly archiveContextKey = input(null); readonly archiveDays = input(0); readonly activeProgram = input(null); readonly isLivePlayback = input(true); @@ -102,6 +105,7 @@ export class EpgListViewComponent { readonly collapsedChange = output(); readonly openGuide = output(); + private readonly destroyRef = inject(DestroyRef); private readonly programmeDialog = inject(EpgProgrammeDialogService); private readonly translate = inject(TranslateService); @@ -280,18 +284,27 @@ export class EpgListViewComponent { } openDetails(row: EpgListRow): void { + const archiveContextKey = this.archiveContextKey(); this.programmeDialog .open({ ...row.program, channelName: this.channelName(), channelLogo: this.channelLogo(), + archiveUrlAvailable: row.canCatchUp, primaryAction: epgDialogActionFor(row.when, row.canCatchUp), archiveUnavailableNote: row.when === 'past' && !this.archivePlaybackAvailable(), }) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((result: EpgItemDialogAction | undefined) => { + if (archiveContextKey !== this.archiveContextKey()) return; if (result === 'live') { this.returnToLive.emit(); + } else if (result === 'copy-catchup-url' && row.canCatchUp) { + this.programActivated.emit({ + program: row.program, + type: result, + }); } else if (result === 'timeshift') { this.selectedKey.set(row.key); this.programActivated.emit({ diff --git a/libs/ui/epg/src/lib/epg-program-activation-event.ts b/libs/ui/epg/src/lib/epg-program-activation-event.ts index 9eef35b22..3be6094ec 100644 --- a/libs/ui/epg/src/lib/epg-program-activation-event.ts +++ b/libs/ui/epg/src/lib/epg-program-activation-event.ts @@ -2,5 +2,5 @@ import type { EpgProgram } from '@iptvnator/shared/interfaces'; export interface EpgProgramActivationEvent { program: EpgProgram; - type: 'live' | 'timeshift'; + type: 'live' | 'timeshift' | 'copy-catchup-url'; } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts index dbbe19544..a6e43cfdc 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts @@ -188,6 +188,58 @@ describe('EpgTimelineComponent', () => { expect(returned).toBe(true); }); + it('forwards archive copy separately from playback and keeps selection', () => { + const past = programAt(-180, 60, 'Earlier'); + setInputs({ + programs: [past], + selectedDate: localDateKey(past.start), + archivePlaybackAvailable: true, + archiveDays: 7, + }); + jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({ + afterClosed: () => of('copy-catchup-url'), + } as never); + const events: string[] = []; + component.programActivated.subscribe((event) => + events.push(event.type) + ); + const selection = component.selectedKey(); + component.openDetails( + component + .blocks() + .find((block) => block.when === 'past') as TimelineBlock + ); + expect(events).toEqual(['copy-catchup-url']); + expect(component.selectedKey()).toBe(selection); + }); + + it('does not copy a programme from a dialog belonging to the previous channel', () => { + const past = programAt(-180, 60, 'Earlier'); + setInputs({ + programs: [past], + selectedDate: localDateKey(past.start), + archivePlaybackAvailable: true, + archiveDays: 7, + archiveContextKey: 'source:channel-a', + }); + const result = new BehaviorSubject(undefined); + jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({ + afterClosed: () => result, + } as never); + const events: string[] = []; + component.programActivated.subscribe((event) => + events.push(event.type) + ); + component.openDetails( + component + .blocks() + .find((block) => block.when === 'past') as TimelineBlock + ); + setInputs({ archiveContextKey: 'source:channel-b' }); + result.next('copy-catchup-url'); + expect(events).toEqual([]); + }); + it('emits the centred day when stepping forward', () => { setInputs({ programs: [programAt(-30, 60)] }); const emitted: string[] = []; diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index f5959ea2b..1b7a80750 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, + DestroyRef, computed, effect, ElementRef, @@ -13,7 +14,7 @@ import { untracked, viewChild, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { normalizeDateLocale } from '@iptvnator/pipes'; @@ -90,6 +91,8 @@ export class EpgTimelineComponent { readonly channelLogo = input(''); readonly sourceLabel = input('Timeline'); readonly archivePlaybackAvailable = input(false); + /** Source/channel identity captured by programme details, independent of playback. */ + readonly archiveContextKey = input(null); readonly archiveDays = input(0); readonly activeProgram = input(null); readonly isLivePlayback = input(true); @@ -111,6 +114,7 @@ export class EpgTimelineComponent { readonly collapsedChange = output(); readonly openGuide = output(); + private readonly destroyRef = inject(DestroyRef); private readonly programmeDialog = inject(EpgProgrammeDialogService); private readonly translate = inject(TranslateService); @@ -378,18 +382,30 @@ export class EpgTimelineComponent { } openDetails(block: TimelineBlock): void { + const archiveContextKey = this.archiveContextKey(); this.programmeDialog .open({ ...block.program, channelName: this.channelName(), channelLogo: this.channelLogo(), + archiveUrlAvailable: this.canCatchUp(block), primaryAction: this.dialogActionFor(block), archiveUnavailableNote: block.when === 'past' && !this.archivePlaybackAvailable(), }) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((result: EpgItemDialogAction | undefined) => { + if (archiveContextKey !== this.archiveContextKey()) return; if (result === 'live') { this.returnToLive.emit(); + } else if ( + result === 'copy-catchup-url' && + this.canCatchUp(block) + ) { + this.programActivated.emit({ + program: block.program, + type: result, + }); } else if (result === 'timeshift') { this.selectedKey.set(block.key); this.programActivated.emit({