diff --git a/.changes/ui-live-row-vertical-actions.md b/.changes/ui-live-row-vertical-actions.md
new file mode 100644
index 000000000..763b73c07
--- /dev/null
+++ b/.changes/ui-live-row-vertical-actions.md
@@ -0,0 +1,11 @@
+---
+type: feature
+area: ui
+issues: [1128]
+---
+
+Live channel rows now stack their action buttons vertically — favorite on
+top, programme info below — so the extra button costs no horizontal space.
+The programme-info button is now also available in the Xtream and Stalker
+channel sidebars, and its slot stays reserved while EPG data loads, so
+buttons never jump.
diff --git a/.changes/xtream-catchup-indicator.md b/.changes/xtream-catchup-indicator.md
new file mode 100644
index 000000000..47461d763
--- /dev/null
+++ b/.changes/xtream-catchup-indicator.md
@@ -0,0 +1,10 @@
+---
+type: feature
+area: xtream
+issues: [1128]
+---
+
+Live channels with provider catch-up now show a small archive badge — in the
+channel sidebar next to the channel name, on the "all channels" grid cards,
+and in the favorites and recently-viewed lists (including global favorites).
+Hovering it shows the catch-up window in days.
diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts
index d337b6503..f7d11ab2b 100644
--- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts
+++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts
@@ -64,6 +64,12 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
).first();
await expect(channelRow).toBeVisible({ timeout: 20000 });
+ // Provider-declared catch-up (tv_archive=1 in the fixture) is
+ // surfaced as a badge on the sidebar row (#1128).
+ await expect(
+ channelRow.getByTestId('catchup-badge')
+ ).toBeVisible();
+
// Sidebar channel list shows the per-channel "now" programme line.
await expect
.poll(async () =>
diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 751a1c228..b86d52c6a 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "لم يتم العثور على قنوات",
"NO_CHANNELS_FOUND": "لم يتم العثور على قنوات",
"NO_GROUPS_FOUND": "لم يتم العثور على مجموعات",
+ "CATCHUP_AVAILABLE": "Catchup متاح",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup متاح — {{days}} يوم",
"TRY_DIFFERENT_SEARCH": "جرّب مصطلح بحث مختلف",
"DETAILS_DIALOG": {
"TITLE": "تفاصيل القناة",
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index b49ab3d55..919812c5c 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "ما تلقاتش حتى قناة",
"NO_CHANNELS_FOUND": "ما تلقاتش حتى قناة",
"NO_GROUPS_FOUND": "ما تلقاتش حتى مجموعة",
+ "CATCHUP_AVAILABLE": "Catchup متوفر",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup متوفر — {{days}} يوم",
"TRY_DIFFERENT_SEARCH": "جرب كلمة بحث أخرى",
"DETAILS_DIALOG": {
"TITLE": "تفاصيل القناة",
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 9bef73dc4..6f3176e29 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Каналаў не знойдзена",
"NO_CHANNELS_FOUND": "Каналаў не знойдзена",
"NO_GROUPS_FOUND": "Груп не знойдзена",
+ "CATCHUP_AVAILABLE": "Catchup даступны",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup даступны — {{days}} дзён",
"TRY_DIFFERENT_SEARCH": "Паспрабуйце іншы пошукавы запыт",
"DETAILS_DIALOG": {
"TITLE": "Інфармацыя аб канале",
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index f3999c9ca..54c20b104 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Keine Sender gefunden",
"NO_CHANNELS_FOUND": "Keine Sender gefunden",
"NO_GROUPS_FOUND": "Keine Gruppen gefunden",
+ "CATCHUP_AVAILABLE": "Catchup verfügbar",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup verfügbar — {{days}} Tage",
"TRY_DIFFERENT_SEARCH": "Versuchen Sie einen anderen Suchbegriff",
"DETAILS_DIALOG": {
"TITLE": "Senderdetails",
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index 7d236cfc9..9807e876d 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Δεν βρέθηκαν κανάλια",
"NO_CHANNELS_FOUND": "Δεν βρέθηκαν κανάλια",
"NO_GROUPS_FOUND": "Δεν βρέθηκαν ομάδες",
+ "CATCHUP_AVAILABLE": "Διαθέσιμο Catchup",
+ "CATCHUP_AVAILABLE_DAYS": "Διαθέσιμο Catchup — {{days}} ημέρες",
"TRY_DIFFERENT_SEARCH": "Δοκιμάστε διαφορετικό όρο αναζήτησης",
"DETAILS_DIALOG": {
"TITLE": "Λεπτομέρειες καναλιού",
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index 24902616f..7424a26b8 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "No channels found",
"NO_CHANNELS_FOUND": "No channels found",
"NO_GROUPS_FOUND": "No groups found",
+ "CATCHUP_AVAILABLE": "Catchup available",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup available — {{days}} days",
"TRY_DIFFERENT_SEARCH": "Try a different search term",
"DETAILS_DIALOG": {
"TITLE": "Channel details",
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index 4a5ffd4f5..a709cbeec 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "No se encontraron canales",
"NO_CHANNELS_FOUND": "No se encontraron canales",
"NO_GROUPS_FOUND": "No se encontraron grupos",
+ "CATCHUP_AVAILABLE": "Catchup disponible",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup disponible — {{days}} días",
"TRY_DIFFERENT_SEARCH": "Prueba con otro término de búsqueda",
"DETAILS_DIALOG": {
"TITLE": "Detalles del canal",
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index f1663f46a..d79ac9f7c 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Aucune chaîne trouvée",
"NO_CHANNELS_FOUND": "Aucune chaîne trouvée",
"NO_GROUPS_FOUND": "Aucun groupe trouvé",
+ "CATCHUP_AVAILABLE": "Catchup disponible",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup disponible — {{days}} jours",
"TRY_DIFFERENT_SEARCH": "Essayez un autre terme de recherche",
"DETAILS_DIALOG": {
"TITLE": "Détails de la chaîne",
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index 29b6aa342..6bbc22a7a 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Nem található csatorna",
"NO_CHANNELS_FOUND": "Nem található csatorna",
"NO_GROUPS_FOUND": "Nem található csoport",
+ "CATCHUP_AVAILABLE": "Visszanézés elérhető",
+ "CATCHUP_AVAILABLE_DAYS": "Visszanézés elérhető — {{days}} nap",
"TRY_DIFFERENT_SEARCH": "Próbálkozzon másik keresési kifejezéssel",
"DETAILS_DIALOG": {
"TITLE": "Csatorna részletei",
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index 8229ae330..b5d2ef763 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Nessun canale trovato",
"NO_CHANNELS_FOUND": "Nessun canale trovato",
"NO_GROUPS_FOUND": "Nessun gruppo trovato",
+ "CATCHUP_AVAILABLE": "Catchup disponibile",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup disponibile — {{days}} giorni",
"TRY_DIFFERENT_SEARCH": "Prova con un altro termine di ricerca",
"DETAILS_DIALOG": {
"TITLE": "Dettagli canale",
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index a4be97d2c..721577a17 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "チャンネルが見つかりません",
"NO_CHANNELS_FOUND": "チャンネルが見つかりません",
"NO_GROUPS_FOUND": "グループが見つかりません",
+ "CATCHUP_AVAILABLE": "Catchup利用可能",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup利用可能 — {{days}}日",
"TRY_DIFFERENT_SEARCH": "別の検索キーワードをお試しください",
"DETAILS_DIALOG": {
"TITLE": "チャンネルの詳細",
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 00adcc3a9..11656c3bb 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "채널을 찾을 수 없습니다",
"NO_CHANNELS_FOUND": "채널을 찾을 수 없습니다",
"NO_GROUPS_FOUND": "그룹을 찾을 수 없습니다",
+ "CATCHUP_AVAILABLE": "Catchup 사용 가능",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup 사용 가능 — {{days}}일",
"TRY_DIFFERENT_SEARCH": "다른 검색어를 시도해 보세요",
"DETAILS_DIALOG": {
"TITLE": "채널 세부정보",
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index 0234ca867..e8de42878 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Geen kanalen gevonden",
"NO_CHANNELS_FOUND": "Geen kanalen gevonden",
"NO_GROUPS_FOUND": "Geen groepen gevonden",
+ "CATCHUP_AVAILABLE": "Catchup beschikbaar",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup beschikbaar — {{days}} dagen",
"TRY_DIFFERENT_SEARCH": "Probeer een andere zoekterm",
"DETAILS_DIALOG": {
"TITLE": "Kanaaldetails",
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index ff30589d7..1113ddebb 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Nie znaleziono kanałów",
"NO_CHANNELS_FOUND": "Nie znaleziono kanałów",
"NO_GROUPS_FOUND": "Nie znaleziono grup",
+ "CATCHUP_AVAILABLE": "Catchup dostępny",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup dostępny — {{days}} dni",
"TRY_DIFFERENT_SEARCH": "Spróbuj innego zapytania",
"DETAILS_DIALOG": {
"TITLE": "Szczegóły kanału",
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index 08ebcc047..d29fbe486 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Nenhum canal encontrado",
"NO_CHANNELS_FOUND": "Nenhum canal encontrado",
"NO_GROUPS_FOUND": "Nenhum grupo encontrado",
+ "CATCHUP_AVAILABLE": "Catchup disponível",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup disponível — {{days}} dias",
"TRY_DIFFERENT_SEARCH": "Tente um termo de pesquisa diferente",
"DETAILS_DIALOG": {
"TITLE": "Detalhes do canal",
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index 1652c870a..7f4762105 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Каналы не найдены",
"NO_CHANNELS_FOUND": "Каналы не найдены",
"NO_GROUPS_FOUND": "Группы не найдены",
+ "CATCHUP_AVAILABLE": "Catchup доступен",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup доступен — {{days}} дн.",
"TRY_DIFFERENT_SEARCH": "Попробуйте другой поисковый запрос",
"DETAILS_DIALOG": {
"TITLE": "Сведения о канале",
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index 00cbbbfbf..e6c4c0353 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "Kanal bulunamadı",
"NO_CHANNELS_FOUND": "Kanal bulunamadı",
"NO_GROUPS_FOUND": "Grup bulunamadı",
+ "CATCHUP_AVAILABLE": "Catchup mevcut",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup mevcut — {{days}} gün",
"TRY_DIFFERENT_SEARCH": "Farklı bir arama terimi deneyin",
"DETAILS_DIALOG": {
"TITLE": "Kanal ayrıntıları",
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index c127aa976..a70d12f72 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "未找到频道",
"NO_CHANNELS_FOUND": "未找到频道",
"NO_GROUPS_FOUND": "未找到分组",
+ "CATCHUP_AVAILABLE": "Catchup 可用",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup 可用 — {{days}} 天",
"TRY_DIFFERENT_SEARCH": "尝试其他搜索词",
"DETAILS_DIALOG": {
"TITLE": "频道详情",
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index 243129808..c1f745555 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -635,6 +635,8 @@
"NO_SEARCH_RESULTS": "找不到頻道",
"NO_CHANNELS_FOUND": "找不到頻道",
"NO_GROUPS_FOUND": "找不到群組",
+ "CATCHUP_AVAILABLE": "Catchup 可用",
+ "CATCHUP_AVAILABLE_DAYS": "Catchup 可用 — {{days}} 天",
"TRY_DIFFERENT_SEARCH": "請嘗試其他搜尋詞",
"DETAILS_DIALOG": {
"TITLE": "頻道詳細資訊",
diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html
index a79a63c55..bd71d5519 100644
--- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html
+++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html
@@ -40,6 +40,8 @@
[isRadio]="ch.radio === 'true'"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
+ [catchupAvailable]="catchupAvailable(ch)"
+ [catchupDays]="catchupDays(ch)"
[showFavoriteButton]="true"
[isFavorite]="isChannelFavorite(ch)"
[showProgramInfoButton]="true"
diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts
index a01b7075a..c7f7c6baa 100644
--- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts
+++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts
@@ -75,6 +75,33 @@ describe('GlobalFavoritesListComponent', () => {
expect(icons).toEqual(['star_outline', 'star']);
});
+ it('shows the catch-up badge on Xtream rows with a playable archive', () => {
+ fixture.componentRef.setInput('channels', [
+ buildChannel('x1', 'Archive Channel', {
+ sourceType: 'xtream',
+ xtreamId: 42,
+ tvArchive: 1,
+ tvArchiveDuration: 7,
+ }),
+ buildChannel('x2', 'Plain Channel', {
+ sourceType: 'xtream',
+ xtreamId: 43,
+ tvArchive: 0,
+ tvArchiveDuration: 0,
+ }),
+ buildChannel('m1', 'M3U Channel'),
+ ]);
+ fixture.detectChanges();
+
+ const badges = fixture.nativeElement.querySelectorAll(
+ '[data-test-id="catchup-badge"]'
+ );
+ expect(badges).toHaveLength(1);
+
+ const badgeRow = badges[0].closest('[data-test-id="channel-item"]');
+ expect(badgeRow?.textContent).toContain('Archive Channel');
+ });
+
it('renders radio rows as compact without a false EPG placeholder', () => {
fixture.componentRef.setInput('channels', [
buildChannel('radio', 'Radio One', { radio: 'true' }),
diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts
index 9fe42c7c0..e16821f47 100644
--- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts
@@ -35,6 +35,8 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
+ getXtreamCatchupDays,
+ isXtreamCatchupAvailable,
sortFavoriteChannelItems,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
@@ -144,6 +146,21 @@ export class GlobalFavoritesListComponent {
});
});
+ /** Catch-up fields arrive camelCase on unified rows — adapt for the
+ * shared snake_case helper. Only Xtream rows carry them. */
+ protected catchupAvailable(channel: UnifiedFavoriteChannel): boolean {
+ return isXtreamCatchupAvailable({
+ tv_archive: channel.tvArchive,
+ tv_archive_duration: channel.tvArchiveDuration,
+ });
+ }
+
+ protected catchupDays(channel: UnifiedFavoriteChannel): number {
+ return getXtreamCatchupDays({
+ tv_archive_duration: channel.tvArchiveDuration,
+ });
+ }
+
onChannelClick(channel: UnifiedFavoriteChannel): void {
this.channelSelected.emit(channel);
}
diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss
index aead84cdc..27fb49d51 100644
--- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss
+++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss
@@ -107,6 +107,41 @@ mat-card:hover {
}
// ─── Rating badge ─────────────────────────────────────────────────────────────
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.catchup-badge {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 24px;
+ height: 24px;
+ // Solid dark pill like .rating — no expensive backdrop-filter
+ background: rgba(0, 0, 0, 0.85);
+ color: rgba(255, 255, 255, 0.88);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+
+ mat-icon {
+ font-size: 15px;
+ width: 15px;
+ height: 15px;
+ line-height: 15px;
+ }
+}
+
.rating {
position: absolute;
top: 6px;
diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts
index 4bd36a133..441991135 100644
--- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts
+++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts
@@ -173,6 +173,70 @@ describe('GridListComponent', () => {
);
});
+ it('shows the catch-up badge on live cards with a playable archive', () => {
+ fixture.componentRef.setInput('items', [
+ {
+ name: 'Archive Channel',
+ stream_icon: 'channel-logo.png',
+ tv_archive: 1,
+ tv_archive_duration: 7,
+ },
+ ]);
+ fixture.componentRef.setInput('variant', 'logo');
+ fixture.componentRef.setInput('type', 'live');
+
+ fixture.detectChanges();
+
+ const badge = fixture.debugElement.query(
+ By.css('[data-test-id="grid-catchup-badge"]')
+ );
+ expect(badge).not.toBeNull();
+ expect(badge.nativeElement.textContent).toContain('history');
+
+ // The icon is aria-hidden — the status must also exist as
+ // visually-hidden text for assistive technology.
+ const srText = badge.query(By.css('.visually-hidden'));
+ expect(srText.nativeElement.textContent).toContain(
+ 'CATCHUP_AVAILABLE'
+ );
+ });
+
+ it('hides the catch-up badge for live cards without a playable archive', () => {
+ fixture.componentRef.setInput('items', [
+ { name: 'Flag Only', tv_archive: 1, tv_archive_duration: 0 },
+ { name: 'No Archive', tv_archive: 0, tv_archive_duration: 7 },
+ { name: 'Legacy Row' },
+ ]);
+ fixture.componentRef.setInput('type', 'live');
+
+ fixture.detectChanges();
+
+ expect(
+ fixture.debugElement.queryAll(
+ By.css('[data-test-id="grid-catchup-badge"]')
+ )
+ ).toHaveLength(0);
+ });
+
+ it('never shows the catch-up badge on VOD grids', () => {
+ fixture.componentRef.setInput('items', [
+ {
+ title: 'Some Movie',
+ tv_archive: 1,
+ tv_archive_duration: 7,
+ },
+ ]);
+ fixture.componentRef.setInput('type', 'vod');
+
+ fixture.detectChanges();
+
+ expect(
+ fixture.debugElement.query(
+ By.css('[data-test-id="grid-catchup-badge"]')
+ )
+ ).toBeNull();
+ });
+
it('renders raw titles while prefix stripping is disabled', () => {
fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]);
fixture.componentRef.setInput('type', 'live');
diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts
index d4be4c1a7..2d77d89ec 100644
--- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts
@@ -13,6 +13,10 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
+import {
+ getXtreamCatchupDays,
+ isXtreamCatchupAvailable,
+} from '@iptvnator/portal/shared/util';
import { SettingsStore } from '@iptvnator/services';
import {
ProgressCapsuleComponent,
@@ -38,6 +42,8 @@ interface GridListItem {
progress?: number;
isWatched?: boolean;
hasSeriesProgress?: boolean;
+ tv_archive?: number | string | null;
+ tv_archive_duration?: number | string | null;
[key: string]: unknown;
}
@@ -136,6 +142,24 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
@if (i.progress && i.progress > 0) {