feat(epg): copy catch-up programme URLs without changing playback

This commit is contained in:
4gray committed 2026-09-07 21:02:13 +02:00
1 parent 97b0264dee
commit dcaeb26849
50 files changed
+850 -157

No files matched your search

+6
View File
@@ -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.
+8
View File
@@ -1019,3 +1019,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
<!-- nx configuration end-->
## 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).
+8
View File
@@ -1830,3 +1830,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).
@@ -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);
}
});
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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)",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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インポート",
+5 -1
View File
@@ -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 가져오기",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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ı",
+5 -1
View File
@@ -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 导入",
+5 -1
View File
@@ -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 匯入",
+17
View File
@@ -1123,6 +1123,23 @@ 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 repeated clicks share one pending
copy operation. 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
@@ -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()`
@@ -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();
});
});
@@ -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()
"
@@ -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;
@@ -26,6 +26,7 @@ export class StubEpgTimelineComponent {
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveContextKey = input<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
@@ -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()
"
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
@@ -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<void>();
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;
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
@@ -73,6 +73,7 @@ export class StubEpgTimelineComponent {
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveContextKey = input<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
@@ -73,6 +73,7 @@ export class StubEpgTimelineComponent {
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveContextKey = input<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
@@ -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()"
@@ -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<string | null>) => {
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(
@@ -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;
+2
View File
@@ -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';
@@ -0,0 +1,75 @@
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('coalesces repeated clicks while the URL is resolving', async () => {
let resolve!: (url: string) => void;
const pending = service.copy(
() =>
new Promise<string>((done) => {
resolve = done;
})
);
const duplicate = jest.fn();
await service.copy(duplicate);
expect(duplicate).not.toHaveBeenCalled();
resolve('https://provider.test/archive.ts');
await pending;
expect(copy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,40 @@
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 pending = false;
async copy(
resolve: () => string | null | Promise<string | null>
): Promise<void> {
if (this.pending) return;
this.pending = true;
let copied = false;
try {
const url = await resolve();
if (url && ['http:', 'https:'].includes(new URL(url).protocol)) {
copied = this.clipboard.copy(url);
}
} catch {
// Provider URLs and error messages may contain credentials.
} finally {
this.pending = false;
}
this.snackBar.open(
this.translate.instant(
copied
? 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_COPIED'
: 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED'
),
undefined,
{ duration: 4000 }
);
}
}
@@ -29,7 +29,9 @@
<div class="epg-dialog__body">
<div class="epg-dialog__meta">
<span>{{ startMs | date: 'EEE d MMM' : '' : currentLocale() }}</span>
<span>{{
startMs | date: 'EEE d MMM' : '' : currentLocale()
}}</span>
<span class="epg-dialog__dot"></span>
<span
>{{ startMs | date: 'HH:mm' }} –
@@ -73,6 +75,11 @@
</div>
}
@if (dialogData.archiveUrlAvailable) {
<p class="epg-dialog__note">
{{ 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' | translate }}
</p>
}
<div class="epg-dialog__actions">
@if (primaryAction === 'live') {
<button
@@ -93,6 +100,17 @@
{{ 'EPG.TIMELINE.WATCH_FROM_START' | translate }}
</button>
}
@if (dialogData.archiveUrlAvailable) {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
data-testid="copy-catchup-url"
[mat-dialog-close]="'copy-catchup-url'"
>
<mat-icon>content_copy</mat-icon>
{{ 'EPG.PROGRAM_DIALOG.COPY_ARCHIVE_URL' | translate }}
</button>
}
@if (archiveUnavailableNote) {
<span class="epg-dialog__note">{{
'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate
@@ -181,6 +181,7 @@
/* ── actions ── */
.epg-dialog__actions {
flex-wrap: wrap;
display: flex;
align-items: center;
gap: 10px;
@@ -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');
@@ -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;
}
@@ -32,12 +32,14 @@ function localDateKey(iso: string): string {
describe('EpgListViewComponent', () => {
let fixture: ComponentFixture<EpgListViewComponent>;
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<string | undefined>(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[] = [];
@@ -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<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
@@ -102,6 +105,7 @@ export class EpgListViewComponent {
readonly collapsedChange = output<boolean>();
readonly openGuide = output<void>();
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({
@@ -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';
}
@@ -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<string | undefined>(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[] = [];
@@ -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<string | null>(null);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
@@ -111,6 +114,7 @@ export class EpgTimelineComponent {
readonly collapsedChange = output<boolean>();
readonly openGuide = output<void>();
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({