mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
feat(sources): add the shared source row and credential-free formatting
One app-source-row (regular for the Sources page, compact for the playlist switcher) with an app-source-tile, built on pure helpers: - resolvePlaylistSourceKind: one provider rule for icons, filters and counts (the list filter and the panel counts used to disagree). - sourceDisplayName / sourceDisplayUrl: a name or URL never shows a password, token or MAC. URL imports that stored "get.php?username=…&password=…" as the title now show the host. - stripUrlCredentials (shared/logging): drops what the log redactor would mask, plus short user/pass keys. - formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line order and Intl dates and durations in the UI language. - SourceStatusService + SourceHealthHistoryService: one status per source (desktop health check, browser Xtream portal status) and the last conclusive result kept across restarts, so "not responding for 3 days" survives a relaunch. No URL or credential is stored. Also fixes playlistNameHasCredentials answering differently on repeated calls (global regex lastIndex), and adds the UI-kit button, menu and segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt in with class="app-menu", styled by the components that use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6fe684af51
commit
028a3db880
51 files changed
+3455
-25
No files matched your search
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "قوائم Stalker",
|
||||
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
|
||||
"PLAYLIST_COUNT_ONE": "قائمة تشغيل واحدة",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · ملف",
|
||||
"ADDED": "أُضيف {{date}}",
|
||||
"UPDATED": "حُدِّث {{date}}",
|
||||
"UNREACHABLE": "لا يستجيب",
|
||||
"UNREACHABLE_FOR": "لا يستجيب · {{duration}}",
|
||||
"EXPIRED": "منتهي",
|
||||
"EDIT": "تعديل…",
|
||||
"COPY_URL": "نسخ الرابط",
|
||||
"OPEN": "فتح"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "الفئات",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "قوائم تشغيل Stalker",
|
||||
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
|
||||
"PLAYLIST_COUNT_ONE": "قائمة تشغيل وحدة",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · فيشي",
|
||||
"ADDED": "تزاد {{date}}",
|
||||
"UPDATED": "تحدّث {{date}}",
|
||||
"UNREACHABLE": "ما كيجاوبش",
|
||||
"UNREACHABLE_FOR": "ما كيجاوبش · {{duration}}",
|
||||
"EXPIRED": "سالات",
|
||||
"EDIT": "بدّل…",
|
||||
"COPY_URL": "نسخ الرابط",
|
||||
"OPEN": "حلّ"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "الفئات",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker плэйлісты",
|
||||
"ALL_PLAYLISTS": "Усе плэйлісты",
|
||||
"PLAYLIST_COUNT_ONE": "1 плэйліст",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · файл",
|
||||
"ADDED": "дададзены {{date}}",
|
||||
"UPDATED": "абноўлены {{date}}",
|
||||
"UNREACHABLE": "Не адказвае",
|
||||
"UNREACHABLE_FOR": "Не адказвае · {{duration}}",
|
||||
"EXPIRED": "Скончыўся",
|
||||
"EDIT": "Змяніць…",
|
||||
"COPY_URL": "Скапіяваць URL",
|
||||
"OPEN": "Адкрыць"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Катэгорыі",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker-Playlists",
|
||||
"ALL_PLAYLISTS": "Alle Playlists",
|
||||
"PLAYLIST_COUNT_ONE": "1 Playlist",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} Playlists"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} Playlists",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · Datei",
|
||||
"ADDED": "hinzugefügt am {{date}}",
|
||||
"UPDATED": "aktualisiert am {{date}}",
|
||||
"UNREACHABLE": "Antwortet nicht",
|
||||
"UNREACHABLE_FOR": "Antwortet nicht · {{duration}}",
|
||||
"EXPIRED": "Abgelaufen",
|
||||
"EDIT": "Bearbeiten…",
|
||||
"COPY_URL": "URL kopieren",
|
||||
"OPEN": "Öffnen"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Kategorien",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker",
|
||||
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
|
||||
"PLAYLIST_COUNT_ONE": "1 λίστα αναπαραγωγής",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · αρχείο",
|
||||
"ADDED": "προστέθηκε {{date}}",
|
||||
"UPDATED": "ενημερώθηκε {{date}}",
|
||||
"UNREACHABLE": "Δεν αποκρίνεται",
|
||||
"UNREACHABLE_FOR": "Δεν αποκρίνεται · {{duration}}",
|
||||
"EXPIRED": "Έληξε",
|
||||
"EDIT": "Επεξεργασία…",
|
||||
"COPY_URL": "Αντιγραφή URL",
|
||||
"OPEN": "Άνοιγμα"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Κατηγορίες",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker Playlists",
|
||||
"ALL_PLAYLISTS": "All Playlists",
|
||||
"PLAYLIST_COUNT_ONE": "1 playlist",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} playlists"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} playlists",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · file",
|
||||
"ADDED": "added {{date}}",
|
||||
"UPDATED": "updated {{date}}",
|
||||
"UNREACHABLE": "Not responding",
|
||||
"UNREACHABLE_FOR": "Not responding · {{duration}}",
|
||||
"EXPIRED": "Expired",
|
||||
"EDIT": "Edit…",
|
||||
"COPY_URL": "Copy URL",
|
||||
"OPEN": "Open"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Categories",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Listas Stalker",
|
||||
"ALL_PLAYLISTS": "Todas las listas",
|
||||
"PLAYLIST_COUNT_ONE": "1 lista de reproducción",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · archivo",
|
||||
"ADDED": "añadido el {{date}}",
|
||||
"UPDATED": "actualizado el {{date}}",
|
||||
"UNREACHABLE": "No responde",
|
||||
"UNREACHABLE_FOR": "No responde · {{duration}}",
|
||||
"EXPIRED": "Caducada",
|
||||
"EDIT": "Editar…",
|
||||
"COPY_URL": "Copiar URL",
|
||||
"OPEN": "Abrir"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Categorías",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Listes de lecture Stalker",
|
||||
"ALL_PLAYLISTS": "Toutes les listes de lecture",
|
||||
"PLAYLIST_COUNT_ONE": "1 liste de lecture",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · fichier",
|
||||
"ADDED": "ajouté le {{date}}",
|
||||
"UPDATED": "mis à jour le {{date}}",
|
||||
"UNREACHABLE": "Ne répond pas",
|
||||
"UNREACHABLE_FOR": "Ne répond pas · {{duration}}",
|
||||
"EXPIRED": "Expiré",
|
||||
"EDIT": "Modifier…",
|
||||
"COPY_URL": "Copier l’URL",
|
||||
"OPEN": "Ouvrir"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Catégories",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker-lejátszási listák",
|
||||
"ALL_PLAYLISTS": "Összes lejátszási lista",
|
||||
"PLAYLIST_COUNT_ONE": "1 lejátszási lista",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · fájl",
|
||||
"ADDED": "hozzáadva: {{date}}",
|
||||
"UPDATED": "frissítve: {{date}}",
|
||||
"UNREACHABLE": "Nem válaszol",
|
||||
"UNREACHABLE_FOR": "Nem válaszol · {{duration}}",
|
||||
"EXPIRED": "Lejárt",
|
||||
"EDIT": "Szerkesztés…",
|
||||
"COPY_URL": "URL másolása",
|
||||
"OPEN": "Megnyitás"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Kategóriák",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Playlist Stalker",
|
||||
"ALL_PLAYLISTS": "Tutte le playlist",
|
||||
"PLAYLIST_COUNT_ONE": "1 playlist",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} playlist"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} playlist",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · file locale",
|
||||
"ADDED": "aggiunto il {{date}}",
|
||||
"UPDATED": "aggiornato il {{date}}",
|
||||
"UNREACHABLE": "Non risponde",
|
||||
"UNREACHABLE_FOR": "Non risponde · {{duration}}",
|
||||
"EXPIRED": "Scaduto",
|
||||
"EDIT": "Modifica…",
|
||||
"COPY_URL": "Copia URL",
|
||||
"OPEN": "Apri"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Categorie",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalkerプレイリスト",
|
||||
"ALL_PLAYLISTS": "すべてのプレイリスト",
|
||||
"PLAYLIST_COUNT_ONE": "1 件のプレイリスト",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · ファイル",
|
||||
"ADDED": "{{date}}に追加",
|
||||
"UPDATED": "{{date}}に更新",
|
||||
"UNREACHABLE": "応答なし",
|
||||
"UNREACHABLE_FOR": "応答なし · {{duration}}",
|
||||
"EXPIRED": "期限切れ",
|
||||
"EDIT": "編集…",
|
||||
"COPY_URL": "URLをコピー",
|
||||
"OPEN": "開く"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "カテゴリー",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker 재생목록",
|
||||
"ALL_PLAYLISTS": "모든 재생목록",
|
||||
"PLAYLIST_COUNT_ONE": "1개 재생목록",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · 파일",
|
||||
"ADDED": "{{date}} 추가됨",
|
||||
"UPDATED": "{{date}} 업데이트됨",
|
||||
"UNREACHABLE": "응답 없음",
|
||||
"UNREACHABLE_FOR": "응답 없음 · {{duration}}",
|
||||
"EXPIRED": "만료됨",
|
||||
"EDIT": "편집…",
|
||||
"COPY_URL": "URL 복사",
|
||||
"OPEN": "열기"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "카테고리",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker-afspeellijsten",
|
||||
"ALL_PLAYLISTS": "Alle afspeellijsten",
|
||||
"PLAYLIST_COUNT_ONE": "1 afspeellijst",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · bestand",
|
||||
"ADDED": "toegevoegd {{date}}",
|
||||
"UPDATED": "bijgewerkt {{date}}",
|
||||
"UNREACHABLE": "Reageert niet",
|
||||
"UNREACHABLE_FOR": "Reageert niet · {{duration}}",
|
||||
"EXPIRED": "Verlopen",
|
||||
"EDIT": "Bewerken…",
|
||||
"COPY_URL": "URL kopiëren",
|
||||
"OPEN": "Openen"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Categorieën",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Listy odtwarzania Stalker",
|
||||
"ALL_PLAYLISTS": "Wszystkie listy odtwarzania",
|
||||
"PLAYLIST_COUNT_ONE": "1 lista odtwarzania",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · plik",
|
||||
"ADDED": "dodano {{date}}",
|
||||
"UPDATED": "zaktualizowano {{date}}",
|
||||
"UNREACHABLE": "Nie odpowiada",
|
||||
"UNREACHABLE_FOR": "Nie odpowiada · {{duration}}",
|
||||
"EXPIRED": "Wygasło",
|
||||
"EDIT": "Edytuj…",
|
||||
"COPY_URL": "Kopiuj URL",
|
||||
"OPEN": "Otwórz"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Kategorie",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Playlists Stalker",
|
||||
"ALL_PLAYLISTS": "Todas as playlists",
|
||||
"PLAYLIST_COUNT_ONE": "1 playlist",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} playlists"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} playlists",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · arquivo",
|
||||
"ADDED": "adicionado em {{date}}",
|
||||
"UPDATED": "atualizado em {{date}}",
|
||||
"UNREACHABLE": "Não responde",
|
||||
"UNREACHABLE_FOR": "Não responde · {{duration}}",
|
||||
"EXPIRED": "Expirada",
|
||||
"EDIT": "Editar…",
|
||||
"COPY_URL": "Copiar URL",
|
||||
"OPEN": "Abrir"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Categorias",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker плейлисты",
|
||||
"ALL_PLAYLISTS": "Все плейлисты",
|
||||
"PLAYLIST_COUNT_ONE": "1 плейлист",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} плейлистов"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} плейлистов",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · файл",
|
||||
"ADDED": "добавлен {{date}}",
|
||||
"UPDATED": "обновлён {{date}}",
|
||||
"UNREACHABLE": "Не отвечает",
|
||||
"UNREACHABLE_FOR": "Не отвечает · {{duration}}",
|
||||
"EXPIRED": "Истёк",
|
||||
"EDIT": "Изменить…",
|
||||
"COPY_URL": "Копировать URL",
|
||||
"OPEN": "Открыть"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Категории",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker Oynatma Listeleri",
|
||||
"ALL_PLAYLISTS": "Tüm Oynatma Listeleri",
|
||||
"PLAYLIST_COUNT_ONE": "1 oynatma listesi",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · dosya",
|
||||
"ADDED": "{{date}} eklendi",
|
||||
"UPDATED": "{{date}} güncellendi",
|
||||
"UNREACHABLE": "Yanıt vermiyor",
|
||||
"UNREACHABLE_FOR": "Yanıt vermiyor · {{duration}}",
|
||||
"EXPIRED": "Süresi doldu",
|
||||
"EDIT": "Düzenle…",
|
||||
"COPY_URL": "URL'yi kopyala",
|
||||
"OPEN": "Aç"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "Kategoriler",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker 播放列表",
|
||||
"ALL_PLAYLISTS": "所有播放列表",
|
||||
"PLAYLIST_COUNT_ONE": "1 个播放列表",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · 文件",
|
||||
"ADDED": "{{date}} 添加",
|
||||
"UPDATED": "{{date}} 更新",
|
||||
"UNREACHABLE": "无响应",
|
||||
"UNREACHABLE_FOR": "无响应 · {{duration}}",
|
||||
"EXPIRED": "已过期",
|
||||
"EDIT": "编辑…",
|
||||
"COPY_URL": "复制 URL",
|
||||
"OPEN": "打开"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "分类",
|
||||
|
||||
@@ -1904,7 +1904,18 @@
|
||||
"STALKER_PLAYLISTS": "Stalker 播放清單",
|
||||
"ALL_PLAYLISTS": "所有播放清單",
|
||||
"PLAYLIST_COUNT_ONE": "1 個播放清單",
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單"
|
||||
"PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單",
|
||||
"ROW": {
|
||||
"TYPE_M3U_FILE": "M3U · 檔案",
|
||||
"ADDED": "{{date}} 新增",
|
||||
"UPDATED": "{{date}} 更新",
|
||||
"UNREACHABLE": "無回應",
|
||||
"UNREACHABLE_FOR": "無回應 · {{duration}}",
|
||||
"EXPIRED": "已過期",
|
||||
"EDIT": "編輯…",
|
||||
"COPY_URL": "複製 URL",
|
||||
"OPEN": "開啟"
|
||||
}
|
||||
},
|
||||
"CONTEXT": {
|
||||
"CATEGORIES": "類別",
|
||||
|
||||
@@ -169,6 +169,23 @@ html {
|
||||
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
|
||||
--app-rating-color: #7a4a00;
|
||||
|
||||
// Source lists (Sources page, playlist switcher) and their popovers.
|
||||
--app-line-strong: rgba(0, 0, 0, 0.12);
|
||||
--app-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
// A failing source's tag and destructive menu items.
|
||||
--app-danger-text: #b3261e;
|
||||
--app-danger-surface: color-mix(in srgb, #d12c2c 12%, transparent);
|
||||
--app-neutral-tag-surface: rgba(0, 0, 0, 0.06);
|
||||
// One selected-row look for side lists and the switcher's current row:
|
||||
// the Settings navigation's soft tint, plus an outline in light theme
|
||||
// (selection reads as outline + tint here, not a solid slab). Light
|
||||
// text stays the heading colour: the blue on its own tint is 3.3:1.
|
||||
--app-list-selected-bg: var(--app-selection-surface);
|
||||
--app-list-selected-fg: var(--app-heading-color);
|
||||
--app-list-selected-outline: var(--app-selection-border);
|
||||
--app-popover-shadow: 0 24px 70px rgba(15, 20, 28, 0.18);
|
||||
--app-popover-backdrop: rgba(20, 24, 32, 0.28);
|
||||
|
||||
.dark-theme {
|
||||
@include mat.all-component-colors($dark-theme);
|
||||
// Only the color roles differ per theme: the elevation shadows are
|
||||
@@ -274,6 +291,19 @@ html {
|
||||
--app-cta-hover-bg: #ffffff;
|
||||
--app-cta-meta-fg: #5a6274;
|
||||
--app-rating-color: #ffcf5c;
|
||||
|
||||
// Source lists and popovers. The selected-row tokens are
|
||||
// re-declared so they resolve against this context's selection
|
||||
// colour, not the light values computed on `html`.
|
||||
--app-line-strong: rgba(255, 255, 255, 0.1);
|
||||
--app-danger-text: #ff8d9b;
|
||||
--app-danger-surface: rgba(255, 91, 110, 0.14);
|
||||
--app-neutral-tag-surface: rgba(255, 255, 255, 0.08);
|
||||
--app-list-selected-bg: var(--app-selection-surface);
|
||||
--app-list-selected-fg: var(--app-selection-color);
|
||||
--app-list-selected-outline: transparent;
|
||||
--app-popover-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
|
||||
--app-popover-backdrop: rgba(4, 6, 10, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ export * from './lib/playlist-refresh-action.service';
|
||||
export * from './lib/xtream-refresh-flow.service';
|
||||
|
||||
export * from './lib/source-health/source-cleanup-dialog.component';
|
||||
export * from './lib/source-row/source-row.component';
|
||||
export * from './lib/source-row/source-tile.component';
|
||||
@@ -0,0 +1,255 @@
|
||||
<div
|
||||
class="source-row"
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="!draggable() || busy()"
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.source-row--compact]="compact()"
|
||||
[class.source-row--current]="current()"
|
||||
[class.source-row--highlighted]="highlighted()"
|
||||
[class.source-row--failing]="failing()"
|
||||
[class.source-row--menu-open]="menuOpen()"
|
||||
[class.source-row--busy]="busy()"
|
||||
(click)="onRowClick()"
|
||||
(keydown)="onRowKeydown($event)"
|
||||
>
|
||||
@if (draggable()) {
|
||||
<mat-icon cdkDragHandle class="source-row__handle" aria-hidden="true"
|
||||
>drag_indicator</mat-icon
|
||||
>
|
||||
}
|
||||
<app-source-tile
|
||||
[playlist]="playlist()"
|
||||
[status]="status()"
|
||||
[size]="compact() ? 'md' : 'lg'"
|
||||
/>
|
||||
<div
|
||||
class="source-row__body"
|
||||
[attr.role]="compact() ? null : 'button'"
|
||||
[attr.tabindex]="compact() ? null : 0"
|
||||
[attr.aria-label]="compact() ? null : name()"
|
||||
[attr.aria-current]="(!compact() && current()) || null"
|
||||
[attr.aria-disabled]="(!compact() && busy()) || null"
|
||||
(keydown.enter)="compact() || onActivationKey($event)"
|
||||
(keydown.space)="compact() || onActivationKey($event)"
|
||||
>
|
||||
<div class="source-row__title">
|
||||
<span class="source-row__name">{{ name() }}</span>
|
||||
@if (tag(); as tag) {
|
||||
<span
|
||||
class="source-row__tag"
|
||||
[class.source-row__tag--neutral]="tag.tone === 'neutral'"
|
||||
>{{ tag.label }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="source-row__meta">
|
||||
@for (part of meta(); track part.kind; let first = $first) {
|
||||
@if (!first) {
|
||||
<span class="source-row__sep" aria-hidden="true">·</span>
|
||||
}
|
||||
<span
|
||||
class="source-row__part"
|
||||
[class.source-row__part--url]="part.kind === 'url'"
|
||||
[class.source-row__part--number]="part.kind === 'channels'"
|
||||
>{{ part.text }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (busy() && busyMessage()) {
|
||||
<div class="source-row__busy" role="status">
|
||||
<span class="source-row__busy-message">{{
|
||||
busyMessage()
|
||||
}}</span>
|
||||
@if (busyProgress() !== null) {
|
||||
<span class="source-row__busy-value"
|
||||
>{{ busyProgress() }}%</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<mat-progress-bar
|
||||
class="source-row__busy-bar"
|
||||
[mode]="
|
||||
busyProgress() === null ? 'indeterminate' : 'determinate'
|
||||
"
|
||||
[value]="busyProgress() ?? 0"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="source-row__actions">
|
||||
@if (compact()) {
|
||||
@if (current()) {
|
||||
<mat-icon class="source-row__check" aria-hidden="true"
|
||||
>check</mat-icon
|
||||
>
|
||||
}
|
||||
<!-- Pointer-only: a focusable control inside a listbox option is
|
||||
an invalid structure. The list opens this menu from the
|
||||
keyboard (Shift+F10). -->
|
||||
<span
|
||||
class="source-row__more source-row__more--compact"
|
||||
aria-hidden="true"
|
||||
[matMenuTriggerFor]="menu"
|
||||
[matMenuTriggerRestoreFocus]="false"
|
||||
(menuOpened)="menuOpen.set(true)"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>more_horiz</mat-icon>
|
||||
</span>
|
||||
} @else {
|
||||
@if (canCancelBusy()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action source-row__action--visible"
|
||||
(click)="
|
||||
$event.stopPropagation(); cancelBusyRequested.emit()
|
||||
"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (failing() && canRecheck()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="source-row__retry"
|
||||
[disabled]="busy()"
|
||||
(click)="$event.stopPropagation(); recheck()"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
{{ 'RETRY' | translate }}
|
||||
</button>
|
||||
} @else if (canRefresh()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action"
|
||||
[class.source-row__action--visible]="refreshing()"
|
||||
[disabled]="busy()"
|
||||
(click)="$event.stopPropagation(); refreshRequested.emit()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
aria-keyshortcuts="R"
|
||||
>
|
||||
@if (refreshing()) {
|
||||
<mat-spinner diameter="14" strokeWidth="2" />
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action"
|
||||
[disabled]="busy()"
|
||||
(click)="$event.stopPropagation(); editRequested.emit()"
|
||||
[matTooltip]="'WORKSPACE.SOURCES.ROW.EDIT' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SOURCES.ROW.EDIT' | translate"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action source-row__more"
|
||||
[disabled]="busy()"
|
||||
[matMenuTriggerFor]="menu"
|
||||
(menuOpened)="menuOpen.set(true)"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate"
|
||||
[attr.aria-label]="
|
||||
'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>more_horiz</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-menu #menu="matMenu" class="app-menu" xPosition="before">
|
||||
<ng-template matMenuContent>
|
||||
@if (canRefresh()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="refreshRequested.emit()"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.REFRESH' | translate }}</span>
|
||||
@if (!compact()) {
|
||||
<kbd class="app-menu-item__kbd" aria-hidden="true">R</kbd>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (compact()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="playlistInfoRequested.emit()"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
|
||||
</button>
|
||||
@if (hasAccountInfo()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="accountInfoRequested.emit()"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button mat-menu-item type="button" (click)="editRequested.emit()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SOURCES.ROW.EDIT' | translate }}</span>
|
||||
</button>
|
||||
@if (copyUrl()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="copyUrlToClipboard()"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SOURCES.ROW.COPY_URL' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
<button mat-menu-item type="button" (click)="activated.emit()">
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SOURCES.ROW.OPEN' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (canRecheck()) {
|
||||
<button mat-menu-item type="button" (click)="recheck()">
|
||||
<mat-icon>network_check</mat-icon>
|
||||
<span>{{ 'SOURCE_HEALTH.RECHECK' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
class="app-menu-item--danger"
|
||||
(click)="removeRequested.emit()"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.REMOVE' | translate }}</span>
|
||||
</button>
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
@@ -0,0 +1,341 @@
|
||||
@use '../../../../../../ui/styles/app-controls' as controls;
|
||||
@use '../../../../../../ui/styles/focus-ring';
|
||||
|
||||
// `--source-row-base` is the surface the list sits on (the page or the
|
||||
// popover). Row fills are solid mixes of it, so the status dot's ring, which
|
||||
// takes the row's fill, always matches what is behind the dot.
|
||||
:host {
|
||||
display: block;
|
||||
--source-row-base: var(--app-content-bg);
|
||||
}
|
||||
|
||||
.source-row {
|
||||
--source-row-fill: var(--source-row-base);
|
||||
--source-tile-ring: var(--source-row-fill);
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background: var(--source-row-fill);
|
||||
cursor: pointer;
|
||||
|
||||
&:has(> .source-row__handle) {
|
||||
grid-template-columns: 18px 36px minmax(0, 1fr) auto;
|
||||
gap: 10px 14px;
|
||||
padding-inline-start: 4px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&.source-row--menu-open {
|
||||
--source-row-fill: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 5%,
|
||||
var(--source-row-base)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.source-row--busy {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// ── Compact (playlist switcher) ──────────────────────────────────────────
|
||||
.source-row--compact {
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
padding: 0 8px;
|
||||
border-radius: 9px;
|
||||
|
||||
&.source-row--highlighted {
|
||||
--source-row-fill: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 7%,
|
||||
var(--source-row-base)
|
||||
);
|
||||
// The listbox keeps focus in its search field; the highlighted
|
||||
// option is the focus indicator.
|
||||
@include focus-ring.focus-ring-declarations($offset: -2px);
|
||||
}
|
||||
|
||||
&.source-row--current {
|
||||
// The same selected-row look as the side lists: a soft tint, and an
|
||||
// outline in light theme. The dot's ring approximates the tint.
|
||||
--source-tile-ring: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 16%,
|
||||
var(--source-row-base)
|
||||
);
|
||||
background: var(--app-list-selected-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--app-list-selected-outline);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tile column ──────────────────────────────────────────────────────────
|
||||
.source-row__handle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
color: var(--app-muted-color);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.source-row--failing app-source-tile {
|
||||
--source-tile-color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
// ── Text column ──────────────────────────────────────────────────────────
|
||||
.source-row__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
// The row draws this element's focus ring.
|
||||
outline: none;
|
||||
|
||||
.source-row--compact & {
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// The keyboard ring belongs to the whole row, the unit that opens; inset,
|
||||
// since the list's scroller would cut one drawn outside the row.
|
||||
.source-row:has(> .source-row__body:focus-visible) {
|
||||
@include focus-ring.focus-ring-declarations($offset: -2px);
|
||||
}
|
||||
|
||||
.source-row__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-row__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: var(--app-heading-color);
|
||||
|
||||
.source-row--compact & {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.source-row--failing & {
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__tag {
|
||||
flex: none;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-danger-surface);
|
||||
color: var(--app-danger-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-row__tag--neutral {
|
||||
background: var(--app-neutral-tag-surface);
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.source-row__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.35;
|
||||
color: var(--app-body-color);
|
||||
|
||||
.source-row--compact & {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__sep {
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.source-row__part {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.source-row__part--number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-row__part--url {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--app-font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.source-row__busy {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.source-row__busy-message {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-row__busy-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-row__busy-bar {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
.source-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.source-row__action {
|
||||
@include controls.ghost-icon-button(32px, 14px);
|
||||
}
|
||||
|
||||
.source-row__retry {
|
||||
@include controls.ghost-button(sm);
|
||||
}
|
||||
|
||||
// Row actions appear on hover, keyboard focus and while the menu is open.
|
||||
// A failing row keeps Retry in view; touch screens, which cannot hover,
|
||||
// always show them.
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.source-row:hover,
|
||||
.source-row:focus-within,
|
||||
.source-row--menu-open,
|
||||
.source-row--current,
|
||||
.source-row--highlighted {
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__action--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
.source-row__more--compact {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
color: var(--app-body-color);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag and drop (custom order) ─────────────────────────────────────────
|
||||
.cdk-drag-preview {
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cdk-drag-animating {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Phones keep the ⋯ menu, which holds every action, and drop the rest.
|
||||
@media (max-width: 480px) {
|
||||
.source-row {
|
||||
gap: 10px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.source-row__action:not(.source-row__more):not(
|
||||
.source-row__action--visible
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-row__part--url,
|
||||
.source-row__sep:has(+ .source-row__part--url) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// UI-kit menu panel. The panel renders in the overlay container, so the
|
||||
// rule is global; it ships with the components that use `class="app-menu"`
|
||||
// rather than in the app's initial stylesheet.
|
||||
::ng-deep .mat-mdc-menu-panel.app-menu {
|
||||
@include controls.menu-panel;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces';
|
||||
import { SourceRowComponent } from './source-row.component';
|
||||
|
||||
const M3U: PlaylistMeta = {
|
||||
_id: 'm3u',
|
||||
title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
url: 'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
count: 1803,
|
||||
importDate: new Date(2026, 8, 18).toISOString(),
|
||||
autoRefresh: false,
|
||||
};
|
||||
const LOCAL: PlaylistMeta = {
|
||||
_id: 'local',
|
||||
title: 'playlist (13)',
|
||||
count: 3220,
|
||||
importDate: new Date(2026, 8, 30).toISOString(),
|
||||
autoRefresh: false,
|
||||
};
|
||||
|
||||
describe('SourceRowComponent', () => {
|
||||
let fixture: ComponentFixture<SourceRowComponent>;
|
||||
let statuses: Map<string, SourceStatus>;
|
||||
let statusService: {
|
||||
statusOf: jest.Mock;
|
||||
tracks: jest.Mock;
|
||||
ensureChecked: jest.Mock;
|
||||
recheck: jest.Mock;
|
||||
};
|
||||
|
||||
const render = (
|
||||
playlist: PlaylistMeta,
|
||||
inputs: Record<string, unknown> = {}
|
||||
): HTMLElement => {
|
||||
fixture = TestBed.createComponent(SourceRowComponent);
|
||||
fixture.componentRef.setInput('playlist', playlist);
|
||||
for (const [name, value] of Object.entries(inputs)) {
|
||||
fixture.componentRef.setInput(name, value);
|
||||
}
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
statuses = new Map();
|
||||
statusService = {
|
||||
statusOf: jest.fn((p: PlaylistMeta) =>
|
||||
p.url || p.serverUrl ? (statuses.get(p._id) ?? null) : null
|
||||
),
|
||||
tracks: jest.fn((p: PlaylistMeta) => !!(p.url || p.serverUrl)),
|
||||
ensureChecked: jest.fn(),
|
||||
recheck: jest.fn(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SourceRowComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: SourceStatusService, useValue: statusService },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the host and a credential-free URL, never the password', () => {
|
||||
const element = render(M3U);
|
||||
expect(element.querySelector('.source-row__name')?.textContent).toBe(
|
||||
'biggie.to:8080'
|
||||
);
|
||||
expect(
|
||||
element.querySelector('.source-row__part--url')?.textContent
|
||||
).toBe('http://biggie.to:8080/get.php?type=m3u_plus');
|
||||
expect(element.textContent).not.toMatch(/hunter2|password=/);
|
||||
expect(
|
||||
element
|
||||
.querySelector('.source-row__body')
|
||||
?.getAttribute('aria-label')
|
||||
).toBe('biggie.to:8080');
|
||||
});
|
||||
|
||||
it('draws no status dot for a local file', () => {
|
||||
const element = render(LOCAL);
|
||||
expect(element.querySelector('.source-tile__dot')).toBeNull();
|
||||
expect(element.querySelector('.source-row__part--url')).toBeNull();
|
||||
});
|
||||
|
||||
it('tags a failing source and offers Retry, which rechecks it', () => {
|
||||
statuses.set('m3u', { reachability: 'bad' });
|
||||
const element = render(M3U, { canRefresh: true });
|
||||
expect(element.querySelector('.source-row__tag')?.textContent).toBe(
|
||||
'WORKSPACE.SOURCES.ROW.UNREACHABLE'
|
||||
);
|
||||
const retry =
|
||||
element.querySelector<HTMLButtonElement>('.source-row__retry');
|
||||
expect(retry).not.toBeNull();
|
||||
retry?.click();
|
||||
expect(statusService.recheck).toHaveBeenCalledWith(M3U);
|
||||
expect(
|
||||
element
|
||||
.querySelector('.source-tile__dot')
|
||||
?.getAttribute('data-state')
|
||||
).toBe('bad');
|
||||
});
|
||||
|
||||
it('opens on click and on Enter, but not while busy', () => {
|
||||
const element = render(M3U);
|
||||
const opened = jest.fn();
|
||||
fixture.componentInstance.activated.subscribe(opened);
|
||||
|
||||
element.querySelector<HTMLElement>('.source-row')?.click();
|
||||
element
|
||||
.querySelector('.source-row__body')
|
||||
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||
expect(opened).toHaveBeenCalledTimes(2);
|
||||
|
||||
fixture.componentRef.setInput('deleting', true);
|
||||
fixture.detectChanges();
|
||||
element.querySelector<HTMLElement>('.source-row')?.click();
|
||||
expect(opened).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refreshes the focused row with R', () => {
|
||||
const element = render(M3U, { canRefresh: true });
|
||||
const refresh = jest.fn();
|
||||
fixture.componentInstance.refreshRequested.subscribe(refresh);
|
||||
const row = element.querySelector('.source-row');
|
||||
|
||||
row?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'r', bubbles: true })
|
||||
);
|
||||
row?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'r',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is a plain option in the switcher, with a pointer-only menu', () => {
|
||||
const element = render(M3U, { size: 'compact', current: true });
|
||||
expect(
|
||||
element.querySelector('.source-row__body')?.getAttribute('role')
|
||||
).toBeNull();
|
||||
expect(element.querySelector('button')).toBeNull();
|
||||
expect(
|
||||
element
|
||||
.querySelector('.source-row__more--compact')
|
||||
?.getAttribute('aria-hidden')
|
||||
).toBe('true');
|
||||
expect(element.querySelector('.source-row__check')).not.toBeNull();
|
||||
expect(element.querySelector('.source-row__part--url')).toBeNull();
|
||||
});
|
||||
|
||||
it('checks the source while shown and cancels when removed', () => {
|
||||
render(M3U);
|
||||
expect(statusService.ensureChecked).toHaveBeenCalledWith(
|
||||
M3U,
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
const signal = statusService.ensureChecked.mock.calls[0][1];
|
||||
fixture.destroy();
|
||||
expect((signal as AbortSignal).aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not check the source when told not to', () => {
|
||||
render(M3U, { checkHealth: false });
|
||||
expect(statusService.ensureChecked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { injectTranslationTick } from '@iptvnator/services';
|
||||
import {
|
||||
isPortalAccountPlaylist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
sourceCopyUrl,
|
||||
sourceDisplayName,
|
||||
SourceFormatContext,
|
||||
sourceMetaParts,
|
||||
sourceStatusTag,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import { SourceTileComponent } from './source-tile.component';
|
||||
|
||||
/**
|
||||
* One source in a list: the Sources page (`regular`, 56px, hover actions)
|
||||
* or the playlist switcher (`compact`, 52px, a ⋯ menu only).
|
||||
*
|
||||
* The regular row opens from the keyboard through its title block, a
|
||||
* `role="button"` sibling of the drag handle and the actions, so no control
|
||||
* sits inside a button. In the switcher the host is a listbox option driven
|
||||
* by `aria-activedescendant`; its ⋯ is pointer-only and the list opens the
|
||||
* menu from the keyboard (`openMenu()`).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-source-row',
|
||||
imports: [
|
||||
DragDropModule,
|
||||
MatButton,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatMenuModule,
|
||||
MatProgressBar,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
SourceTileComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './source-row.component.html',
|
||||
styleUrl: './source-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourceRowComponent {
|
||||
private readonly statusService = inject(SourceStatusService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly menuTrigger = viewChild(MatMenuTrigger);
|
||||
|
||||
readonly playlist = input.required<PlaylistMeta>();
|
||||
readonly size = input<'regular' | 'compact'>('regular');
|
||||
/** The active playlist (switcher): filled row, check mark, ⋯ shown. */
|
||||
readonly current = input(false);
|
||||
/** The keyboard highlight of the switcher's listbox. */
|
||||
readonly highlighted = input(false);
|
||||
/** Runs a passive reachability check while the row is shown. */
|
||||
readonly checkHealth = input(true);
|
||||
readonly canRefresh = input(false);
|
||||
readonly draggable = input(false);
|
||||
readonly refreshing = input(false);
|
||||
readonly deleting = input(false);
|
||||
readonly busyMessage = input('');
|
||||
readonly busyProgress = input<number | null>(null);
|
||||
readonly canCancelBusy = input(false);
|
||||
|
||||
readonly activated = output<void>();
|
||||
readonly refreshRequested = output<void>();
|
||||
readonly editRequested = output<void>();
|
||||
readonly removeRequested = output<void>();
|
||||
readonly cancelBusyRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly menuClosed = output<void>();
|
||||
|
||||
readonly menuOpen = signal(false);
|
||||
readonly compact = computed(() => this.size() === 'compact');
|
||||
readonly busy = computed(() => this.refreshing() || this.deleting());
|
||||
readonly status = computed(() =>
|
||||
this.statusService.statusOf(this.playlist())
|
||||
);
|
||||
readonly failing = computed(() => {
|
||||
const reachability = this.status()?.reachability;
|
||||
return reachability === 'bad' || reachability === 'expired';
|
||||
});
|
||||
readonly canRecheck = computed(() =>
|
||||
this.statusService.tracks(this.playlist())
|
||||
);
|
||||
readonly copyUrl = computed(() => sourceCopyUrl(this.playlist()));
|
||||
readonly hasAccountInfo = computed(() =>
|
||||
isPortalAccountPlaylist(this.playlist())
|
||||
);
|
||||
|
||||
private readonly formatContext = computed<SourceFormatContext>(() => {
|
||||
this.languageTick();
|
||||
return {
|
||||
translate: (key, params) => this.translate.instant(key, params),
|
||||
locale: normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
),
|
||||
now: Date.now(),
|
||||
};
|
||||
});
|
||||
readonly name = computed(() => {
|
||||
this.languageTick();
|
||||
return (
|
||||
sourceDisplayName(this.playlist()) ??
|
||||
this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
|
||||
);
|
||||
});
|
||||
readonly meta = computed(() =>
|
||||
sourceMetaParts(this.playlist(), this.formatContext(), this.compact())
|
||||
);
|
||||
readonly tag = computed(() =>
|
||||
sourceStatusTag(this.status(), this.formatContext(), this.compact())
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const playlist = this.playlist();
|
||||
if (!this.checkHealth()) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
untracked(() =>
|
||||
this.statusService.ensureChecked(playlist, controller.signal)
|
||||
);
|
||||
onCleanup(() => controller.abort());
|
||||
});
|
||||
}
|
||||
|
||||
onRowClick(): void {
|
||||
if (!this.busy()) {
|
||||
this.activated.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter/Space on the title block; Space must not scroll the page. */
|
||||
onActivationKey(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.onRowClick();
|
||||
}
|
||||
|
||||
/** `R` refreshes the focused row, as its menu item says. */
|
||||
onRowKeydown(event: KeyboardEvent): void {
|
||||
const plain =
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey;
|
||||
if (
|
||||
plain &&
|
||||
event.key.toLowerCase() === 'r' &&
|
||||
!this.compact() &&
|
||||
this.canRefresh() &&
|
||||
!this.busy()
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.refreshRequested.emit();
|
||||
}
|
||||
}
|
||||
|
||||
recheck(): void {
|
||||
this.statusService.recheck(this.playlist());
|
||||
}
|
||||
|
||||
copyUrlToClipboard(): void {
|
||||
const url = this.copyUrl();
|
||||
if (url && this.clipboard.copy(url)) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the ⋯ menu from the keyboard (switcher: Shift+F10). */
|
||||
openMenu(): void {
|
||||
this.menuTrigger()?.openMenu();
|
||||
}
|
||||
|
||||
onMenuClosed(): void {
|
||||
this.menuOpen.set(false);
|
||||
this.menuClosed.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// The provider tile of a source row and the switcher trigger. The dot's
|
||||
// 2px ring takes `--source-tile-ring`, which the row sets to its own
|
||||
// background, so the dot reads as cut out of the tile in every state.
|
||||
:host {
|
||||
--source-tile-size: 36px;
|
||||
--source-tile-radius: 9px;
|
||||
--source-tile-icon: 17px;
|
||||
--source-tile-dot: 11px;
|
||||
--source-tile-dot-offset: -3px;
|
||||
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--source-tile-size);
|
||||
height: var(--source-tile-size);
|
||||
border-radius: var(--source-tile-radius);
|
||||
background: var(--source-tile-bg, var(--app-widget-bg));
|
||||
color: var(--source-tile-color, var(--app-body-color));
|
||||
}
|
||||
|
||||
:host(.source-tile--md) {
|
||||
--source-tile-size: 32px;
|
||||
--source-tile-radius: 8px;
|
||||
--source-tile-icon: 15px;
|
||||
--source-tile-dot: 9px;
|
||||
}
|
||||
|
||||
:host(.source-tile--sm) {
|
||||
--source-tile-size: 28px;
|
||||
--source-tile-radius: 7px;
|
||||
--source-tile-icon: 14px;
|
||||
--source-tile-dot: 9px;
|
||||
--source-tile-dot-offset: -2px;
|
||||
}
|
||||
|
||||
.source-tile__icon {
|
||||
width: var(--source-tile-icon);
|
||||
height: var(--source-tile-icon);
|
||||
font-size: var(--source-tile-icon);
|
||||
line-height: var(--source-tile-icon);
|
||||
}
|
||||
|
||||
.source-tile__dot {
|
||||
position: absolute;
|
||||
right: var(--source-tile-dot-offset);
|
||||
bottom: var(--source-tile-dot-offset);
|
||||
box-sizing: border-box;
|
||||
width: var(--source-tile-dot);
|
||||
height: var(--source-tile-dot);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--source-tile-ring, var(--app-content-bg));
|
||||
background: var(--app-muted-color);
|
||||
|
||||
&[data-state='ok'] {
|
||||
background: var(--app-success-color);
|
||||
}
|
||||
|
||||
&[data-state='bad'] {
|
||||
background: var(--app-live-color);
|
||||
}
|
||||
|
||||
&[data-state='expired'] {
|
||||
background: var(--app-expiry-warn-text);
|
||||
}
|
||||
}
|
||||
|
||||
.source-tile__dot--pending {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { injectTranslationTick } from '@iptvnator/services';
|
||||
import {
|
||||
getPlaylistSourceIcon,
|
||||
PlaylistMeta,
|
||||
SourceReachability,
|
||||
SourceStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** The health reason shown for each reachability when no detail is kept. */
|
||||
const REACHABILITY_REASON: Record<SourceReachability, string> = {
|
||||
ok: 'available',
|
||||
bad: 'network',
|
||||
expired: 'expired',
|
||||
checking: 'checking',
|
||||
unknown: 'unknown',
|
||||
};
|
||||
|
||||
/**
|
||||
* The provider icon on a rounded tile, with the status dot at its corner:
|
||||
* green answers, red does not, orange is an expired account, grey is not
|
||||
* checked yet. Local files have no dot.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-source-tile',
|
||||
imports: [MatIcon, MatTooltip],
|
||||
template: `
|
||||
<mat-icon class="source-tile__icon" aria-hidden="true">{{
|
||||
icon()
|
||||
}}</mat-icon>
|
||||
@if (status(); as status) {
|
||||
<span
|
||||
class="source-tile__dot"
|
||||
role="img"
|
||||
[attr.data-state]="dotState()"
|
||||
[class.source-tile__dot--pending]="status.pending"
|
||||
[attr.aria-label]="description()"
|
||||
[matTooltip]="description()"
|
||||
></span>
|
||||
}
|
||||
`,
|
||||
styleUrl: './source-tile.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'source-tile',
|
||||
'[class.source-tile--sm]': "size() === 'sm'",
|
||||
'[class.source-tile--md]': "size() === 'md'",
|
||||
'[class.source-tile--lg]': "size() === 'lg'",
|
||||
},
|
||||
})
|
||||
export class SourceTileComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly playlist =
|
||||
input.required<
|
||||
Pick<PlaylistMeta, 'macAddress' | 'serverUrl' | 'url'>
|
||||
>();
|
||||
readonly status = input<SourceStatus | null>(null);
|
||||
/** 28px (trigger), 32px (switcher) or 36px (Sources page). */
|
||||
readonly size = input<'sm' | 'md' | 'lg'>('lg');
|
||||
|
||||
readonly icon = computed(() => getPlaylistSourceIcon(this.playlist()));
|
||||
readonly dotState = computed(() => {
|
||||
const reachability = this.status()?.reachability;
|
||||
return reachability === 'checking' ? 'unknown' : reachability;
|
||||
});
|
||||
readonly description = computed(() => {
|
||||
this.languageTick();
|
||||
const status = this.status();
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
const reason = status.pending
|
||||
? 'checking'
|
||||
: REACHABILITY_REASON[status.reachability];
|
||||
const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
|
||||
const time = status.checkedAt
|
||||
? new Date(status.checkedAt).toLocaleString(
|
||||
normalizeDateLocale(this.translate.currentLang || 'en'),
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}
|
||||
)
|
||||
: '';
|
||||
return [label, time].filter(Boolean).join(' · ');
|
||||
});
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './lib/playlist-context.facade';
|
||||
export * from './lib/playlist-file-import.service';
|
||||
export * from './lib/source-display/source-display';
|
||||
export * from './lib/source-display/source-format';
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
sourceCopyUrl,
|
||||
sourceDisplayName,
|
||||
sourceDisplayUrl,
|
||||
sourceHost,
|
||||
} from './source-display';
|
||||
|
||||
const M3U_URL =
|
||||
'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus';
|
||||
|
||||
describe('sourceDisplayName', () => {
|
||||
it('keeps a name the user chose', () => {
|
||||
expect(sourceDisplayName({ title: 'Free TV', url: M3U_URL })).toBe(
|
||||
'Free TV'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the host when the source has no name', () => {
|
||||
expect(sourceDisplayName({ title: '', url: M3U_URL })).toBe(
|
||||
'biggie.to:8080'
|
||||
);
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: '',
|
||||
serverUrl: 'http://xtream.tv:80',
|
||||
username: 'joe',
|
||||
password: 'hunter2',
|
||||
})
|
||||
).toBe('xtream.tv');
|
||||
});
|
||||
|
||||
it('shows the host for the name an URL import derives', () => {
|
||||
// The import stores the URL's last segment, query and all.
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
url: M3U_URL,
|
||||
})
|
||||
).toBe('biggie.to:8080');
|
||||
});
|
||||
|
||||
it('shows the host for a pasted URL', () => {
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'https://cdn.tv/lists/clean.m3u',
|
||||
url: 'https://cdn.tv/lists/clean.m3u',
|
||||
})
|
||||
).toBe('cdn.tv');
|
||||
});
|
||||
|
||||
it('drops a name that carries a credential', () => {
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'My portal pass== hunter2',
|
||||
serverUrl: 'http://xtream.tv',
|
||||
})
|
||||
).toBe('xtream.tv');
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'backup hunter2',
|
||||
serverUrl: 'http://xtream.tv',
|
||||
password: 'hunter2',
|
||||
})
|
||||
).toBe('xtream.tv');
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'Box 00:1A:79:00:00:01',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.tv/c/',
|
||||
})
|
||||
).toBe('portal.tv');
|
||||
});
|
||||
|
||||
it('keeps an account name in the name: it is shown by design', () => {
|
||||
expect(sourceDisplayName({ title: 'joe', url: M3U_URL })).toBe('joe');
|
||||
});
|
||||
|
||||
it('returns null for a local file without a name', () => {
|
||||
expect(sourceDisplayName({ title: '' })).toBeNull();
|
||||
expect(sourceDisplayName({ title: '', filename: 'list.m3u' })).toBe(
|
||||
'list.m3u'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceDisplayUrl', () => {
|
||||
it('shows an M3U URL without credentials', () => {
|
||||
expect(sourceDisplayUrl({ url: M3U_URL })).toBe(
|
||||
'http://biggie.to:8080/get.php?type=m3u_plus'
|
||||
);
|
||||
});
|
||||
|
||||
it('shortens a long path to its last segment', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
url: 'https://raw.githubusercontent.com/user/repo/main/clean.m3u',
|
||||
})
|
||||
).toBe('https://raw.githubusercontent.com/…/clean.m3u');
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
url: 'https://iptv-org.github.io/iptv/index.m3u',
|
||||
})
|
||||
).toBe('https://iptv-org.github.io/iptv/index.m3u');
|
||||
});
|
||||
|
||||
it('shows host, port and account for Xtream, never the password', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
serverUrl: 'http://xtream.tv:8080/',
|
||||
username: 'joe',
|
||||
password: 'hunter2',
|
||||
})
|
||||
).toBe('http://xtream.tv:8080 · joe');
|
||||
});
|
||||
|
||||
it('shows the portal URL for Stalker, never the MAC', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl:
|
||||
'http://portal.tv/stalker_portal/c/?mac=00:1A:79:00:00:01',
|
||||
})
|
||||
).toBe('http://portal.tv/stalker_portal/c/');
|
||||
});
|
||||
|
||||
it('falls back to the origin when a secret sits in the path', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.tv/00:1A:79:00:00:01/c/',
|
||||
})
|
||||
).toBe('http://portal.tv');
|
||||
});
|
||||
|
||||
it('has no URL for a local file or an unparsable address', () => {
|
||||
expect(sourceDisplayUrl({ title: 'local' })).toBeNull();
|
||||
expect(sourceDisplayUrl({ url: 'not a url' })).toBeNull();
|
||||
expect(
|
||||
sourceDisplayUrl({ serverUrl: 'nope', username: 'joe' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('never shows a password, token or MAC', () => {
|
||||
const sources = [
|
||||
{ url: M3U_URL },
|
||||
{ url: 'http://joe:hunter2@cdn.tv/a/b/c/list.m3u?token=hunter2' },
|
||||
{ serverUrl: 'http://x.tv', username: 'joe', password: 'hunter2' },
|
||||
{
|
||||
macAddress: 'hunter2',
|
||||
portalUrl: 'http://p.tv/c/?mac=hunter2&sn=hunter2',
|
||||
},
|
||||
];
|
||||
for (const source of sources) {
|
||||
expect(sourceDisplayUrl(source) ?? '').not.toMatch(/hunter2/);
|
||||
expect(
|
||||
sourceDisplayName({ ...source, title: '' }) ?? ''
|
||||
).not.toMatch(/hunter2/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceHost and sourceCopyUrl', () => {
|
||||
it('reads the endpoint by provider precedence', () => {
|
||||
const stalker = {
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.tv/c/',
|
||||
url: 'http://other.tv/list.m3u',
|
||||
};
|
||||
expect(sourceHost(stalker)).toBe('portal.tv');
|
||||
expect(sourceCopyUrl(stalker)).toBe('http://portal.tv/c/');
|
||||
expect(sourceCopyUrl({ url: M3U_URL })).toBe(M3U_URL);
|
||||
expect(sourceCopyUrl({})).toBeNull();
|
||||
expect(sourceHost({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { isSensitiveKey, stripUrlCredentials } from '@iptvnator/shared/logging';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
playlistDisplayLabel,
|
||||
playlistNameHasCredentials,
|
||||
resolvePlaylistSourceKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type SourceDisplayFields = Partial<
|
||||
Pick<
|
||||
PlaylistMeta,
|
||||
| 'title'
|
||||
| 'filename'
|
||||
| 'url'
|
||||
| 'serverUrl'
|
||||
| 'portalUrl'
|
||||
| 'macAddress'
|
||||
| 'username'
|
||||
| 'password'
|
||||
>
|
||||
>;
|
||||
|
||||
/** The account name stays on screen by design; every other secret goes. */
|
||||
const SHOWN_ACCOUNT_KEYS = new Set(['username', 'login']);
|
||||
/** Shorter values match ordinary words, not secrets. */
|
||||
const MIN_SECRET_LENGTH = 3;
|
||||
|
||||
function parseUrl(value: string | undefined): URL | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** The portal, server or playlist URL a source is loaded from. */
|
||||
export function sourceEndpoint(
|
||||
playlist: Pick<
|
||||
SourceDisplayFields,
|
||||
'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
|
||||
>
|
||||
): string | undefined {
|
||||
if (playlist.macAddress) {
|
||||
return playlist.portalUrl || playlist.url;
|
||||
}
|
||||
return playlist.serverUrl || playlist.url;
|
||||
}
|
||||
|
||||
/** Host and non-default port of the endpoint; null for a local file. */
|
||||
export function sourceHost(
|
||||
playlist: Pick<
|
||||
SourceDisplayFields,
|
||||
'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
|
||||
>
|
||||
): string | null {
|
||||
return parseUrl(sourceEndpoint(playlist))?.host || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values that must never be on screen for this source: the password, the
|
||||
* MAC address, and every sensitive parameter of its URL except the account
|
||||
* name, in both decoded and encoded form.
|
||||
*/
|
||||
function sourceSecrets(playlist: SourceDisplayFields): string[] {
|
||||
const secrets = [playlist.password, playlist.macAddress];
|
||||
const url = parseUrl(sourceEndpoint(playlist));
|
||||
if (url) {
|
||||
secrets.push(safeDecode(url.password));
|
||||
url.searchParams.forEach((value, key) => {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (isSensitiveKey(key) && !SHOWN_ACCOUNT_KEYS.has(normalized)) {
|
||||
secrets.push(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
const shown: string[] = [];
|
||||
for (const secret of secrets) {
|
||||
if (secret && secret.length >= MIN_SECRET_LENGTH) {
|
||||
shown.push(secret, encodeURIComponent(secret));
|
||||
}
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
export function containsSourceSecret(
|
||||
text: string,
|
||||
playlist: SourceDisplayFields
|
||||
): boolean {
|
||||
return sourceSecrets(playlist).some((secret) => text.includes(secret));
|
||||
}
|
||||
|
||||
/**
|
||||
* A name an import made from the URL rather than one the user typed: the
|
||||
* URL itself, or the last path segment of a URL with a query, such as
|
||||
* `get.php?username=…&password=…`.
|
||||
*/
|
||||
function isUrlDerivedName(name: string, url: string | undefined): boolean {
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(name)) {
|
||||
return true;
|
||||
}
|
||||
return !!url && name.includes('?') && url.trim().endsWith(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a source list shows. A name the user chose stays; an empty name,
|
||||
* a URL or a URL-derived name becomes the host, and any name that carries a
|
||||
* credential collapses to the host as well. Null when there is nothing safe
|
||||
* to show; the caller renders its "untitled" label.
|
||||
*/
|
||||
export function sourceDisplayName(
|
||||
playlist: SourceDisplayFields
|
||||
): string | null {
|
||||
const raw = (playlist.title || playlist.filename || '').trim();
|
||||
const host = sourceHost(playlist);
|
||||
if (!raw) {
|
||||
return host;
|
||||
}
|
||||
if (
|
||||
isUrlDerivedName(raw, playlist.url) ||
|
||||
playlistNameHasCredentials(raw)
|
||||
) {
|
||||
return host ?? (playlistDisplayLabel(raw) || null);
|
||||
}
|
||||
return containsSourceSecret(raw, playlist) ? host : raw;
|
||||
}
|
||||
|
||||
/** `origin/…/last` once a path has more than two segments. */
|
||||
function compactPath(pathname: string): string {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
return segments.length > 2
|
||||
? `/…/${segments[segments.length - 1]}`
|
||||
: pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* The location a source list shows beside the name, without credentials:
|
||||
* `scheme://host:port · username` for Xtream, the portal URL for Stalker,
|
||||
* the playlist URL with a shortened path for M3U, and null for a local file.
|
||||
*/
|
||||
export function sourceDisplayUrl(playlist: SourceDisplayFields): string | null {
|
||||
const kind = resolvePlaylistSourceKind(playlist);
|
||||
if (kind === 'xtream') {
|
||||
const server = parseUrl(playlist.serverUrl);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
const base = `${server.protocol}//${server.host}`;
|
||||
return playlist.username ? `${base} · ${playlist.username}` : base;
|
||||
}
|
||||
|
||||
const stripped = parseUrl(
|
||||
stripUrlCredentials(sourceEndpoint(playlist) ?? '') ?? undefined
|
||||
);
|
||||
if (!stripped) {
|
||||
return null;
|
||||
}
|
||||
const origin = `${stripped.protocol}//${stripped.host}`;
|
||||
const shown = `${origin}${compactPath(stripped.pathname)}${stripped.search}${stripped.hash}`;
|
||||
return containsSourceSecret(shown, playlist) ? origin : shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full address "Copy URL" puts on the clipboard; credentials included,
|
||||
* since that is what the user asked for. Null for a local file.
|
||||
*/
|
||||
export function sourceCopyUrl(
|
||||
playlist: Pick<
|
||||
SourceDisplayFields,
|
||||
'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
|
||||
>
|
||||
): string | null {
|
||||
return sourceEndpoint(playlist)?.trim() || null;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
formatSourceDate,
|
||||
formatSourceDuration,
|
||||
SourceFormatContext,
|
||||
sourceMetaParts,
|
||||
sourceStatusTag,
|
||||
sourceTypeLabel,
|
||||
} from './source-format';
|
||||
|
||||
const NOW = new Date(2026, 9, 12, 12, 0).getTime();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** Echoes the key and its parameters, so specs read what was asked for. */
|
||||
const translate = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key;
|
||||
|
||||
const context = (locale = 'en'): SourceFormatContext => ({
|
||||
translate,
|
||||
locale,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
describe('formatSourceDate', () => {
|
||||
it('shows day and month in the current year', () => {
|
||||
const date = new Date(2026, 8, 6).toISOString();
|
||||
expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6');
|
||||
expect(formatSourceDate(date, 'ru', NOW)).toMatch(/^6 сент/);
|
||||
});
|
||||
|
||||
it('adds the year for another year', () => {
|
||||
const date = new Date(2025, 8, 6).toISOString();
|
||||
expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6, 2025');
|
||||
});
|
||||
|
||||
it('adds the time on request', () => {
|
||||
const date = new Date(2026, 8, 6, 21, 34).getTime();
|
||||
expect(formatSourceDate(date, 'en-GB', NOW, true)).toBe(
|
||||
'6 Sept, 21:34'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads epoch milliseconds stored as a string', () => {
|
||||
const value = String(new Date(2026, 0, 2).getTime());
|
||||
expect(formatSourceDate(value, 'en', NOW)).toBe('Jan 2');
|
||||
});
|
||||
|
||||
it('returns null for an empty or invalid value', () => {
|
||||
expect(formatSourceDate(undefined, 'en', NOW)).toBeNull();
|
||||
expect(formatSourceDate('', 'en', NOW)).toBeNull();
|
||||
expect(formatSourceDate('not a date', 'en', NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to English for an unknown locale', () => {
|
||||
const date = new Date(2026, 8, 6).toISOString();
|
||||
expect(formatSourceDate(date, 'not_a-locale!', NOW)).toBe('Sep 6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSourceDuration', () => {
|
||||
it('counts whole days, then hours, in the UI language', () => {
|
||||
expect(formatSourceDuration(NOW - 3 * DAY - 5, 'en', NOW)).toBe(
|
||||
'3 days'
|
||||
);
|
||||
expect(formatSourceDuration(NOW - 3 * DAY, 'ru', NOW)).toBe('3 дня');
|
||||
expect(formatSourceDuration(NOW - 5 * 3_600_000, 'en', NOW)).toBe(
|
||||
'5 hours'
|
||||
);
|
||||
});
|
||||
|
||||
it('says nothing under an hour', () => {
|
||||
expect(formatSourceDuration(NOW - 60_000, 'en', NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceMetaParts', () => {
|
||||
const m3u = {
|
||||
title: 'Free TV',
|
||||
url: 'https://cdn.tv/a/b/list.m3u?token=secret1',
|
||||
count: 16805,
|
||||
importDate: new Date(2026, 8, 30).toISOString(),
|
||||
updateDate: new Date(2026, 9, 8, 3, 0).getTime(),
|
||||
};
|
||||
|
||||
it('lists type, channels, added, updated and URL in that order', () => {
|
||||
const parts = sourceMetaParts(m3u, context());
|
||||
expect(parts.map((part) => part.kind)).toEqual([
|
||||
'type',
|
||||
'channels',
|
||||
'added',
|
||||
'updated',
|
||||
'url',
|
||||
]);
|
||||
expect(parts[0].text).toBe('M3U');
|
||||
expect(parts[1].text).toBe(
|
||||
'HOME.PLAYLISTS.CHANNELS_COUNT {"count":"16,805"}'
|
||||
);
|
||||
expect(parts[4].text).toBe('https://cdn.tv/…/list.m3u');
|
||||
});
|
||||
|
||||
it('leaves out empty parts and zero channels', () => {
|
||||
const parts = sourceMetaParts(
|
||||
{ title: 'x', count: 0, importDate: '' },
|
||||
context()
|
||||
);
|
||||
expect(parts).toEqual([
|
||||
{ kind: 'type', text: 'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows no channel count for Xtream and Stalker', () => {
|
||||
const parts = sourceMetaParts(
|
||||
{
|
||||
title: 'x',
|
||||
serverUrl: 'http://x.tv',
|
||||
username: 'joe',
|
||||
password: 'p4ss',
|
||||
count: 0,
|
||||
importDate: '',
|
||||
},
|
||||
context()
|
||||
);
|
||||
expect(parts.map((part) => part.kind)).toEqual(['type', 'url']);
|
||||
expect(parts[0].text).toBe('Xtream');
|
||||
});
|
||||
|
||||
it('keeps type, channels and updated in the compact row', () => {
|
||||
expect(
|
||||
sourceMetaParts(m3u, context(), true).map((part) => part.kind)
|
||||
).toEqual(['type', 'channels', 'updated']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceTypeLabel', () => {
|
||||
it('names every provider', () => {
|
||||
expect(sourceTypeLabel({ url: 'http://x' }, translate)).toBe('M3U');
|
||||
expect(sourceTypeLabel({}, translate)).toBe(
|
||||
'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE'
|
||||
);
|
||||
expect(sourceTypeLabel({ serverUrl: 'http://x' }, translate)).toBe(
|
||||
'Xtream'
|
||||
);
|
||||
expect(sourceTypeLabel({ macAddress: 'm' }, translate)).toBe('Stalker');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceStatusTag', () => {
|
||||
it('says how long a source has not responded', () => {
|
||||
expect(
|
||||
sourceStatusTag(
|
||||
{ reachability: 'bad', failingSince: NOW - 3 * DAY },
|
||||
context()
|
||||
)
|
||||
).toEqual({
|
||||
tone: 'danger',
|
||||
label: 'WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR {"duration":"3 days"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the duration in the compact row and under an hour', () => {
|
||||
const status = {
|
||||
reachability: 'bad',
|
||||
failingSince: NOW - 3 * DAY,
|
||||
} as const;
|
||||
expect(sourceStatusTag(status, context(), true)?.label).toBe(
|
||||
'WORKSPACE.SOURCES.ROW.UNREACHABLE'
|
||||
);
|
||||
expect(
|
||||
sourceStatusTag(
|
||||
{ reachability: 'bad', failingSince: NOW - 1000 },
|
||||
context()
|
||||
)?.label
|
||||
).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE');
|
||||
});
|
||||
|
||||
it('tags expired accounts and unchecked sources', () => {
|
||||
expect(sourceStatusTag({ reachability: 'expired' }, context())).toEqual(
|
||||
{
|
||||
tone: 'danger',
|
||||
label: 'WORKSPACE.SOURCES.ROW.EXPIRED',
|
||||
}
|
||||
);
|
||||
expect(sourceStatusTag({ reachability: 'unknown' }, context())).toEqual(
|
||||
{
|
||||
tone: 'neutral',
|
||||
label: 'SOURCE_HEALTH.REASON.unknown',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
sourceStatusTag({ reachability: 'unknown' }, context(), true)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('has no tag for a healthy, checking or local source', () => {
|
||||
expect(sourceStatusTag({ reachability: 'ok' }, context())).toBeNull();
|
||||
expect(
|
||||
sourceStatusTag({ reachability: 'checking' }, context())
|
||||
).toBeNull();
|
||||
expect(sourceStatusTag(null, context())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
PlaylistMeta,
|
||||
resolvePlaylistSourceKind,
|
||||
SourceStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SourceDisplayFields, sourceDisplayUrl } from './source-display';
|
||||
|
||||
/** `TranslateService.instant` or a test double. */
|
||||
export type SourceTranslate = (
|
||||
key: string,
|
||||
params?: Record<string, unknown>
|
||||
) => string;
|
||||
|
||||
export interface SourceFormatContext {
|
||||
translate: SourceTranslate;
|
||||
/** A BCP 47 locale for `Intl`, e.g. from `normalizeDateLocale`. */
|
||||
locale: string;
|
||||
now: number;
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Provider names are brands, the same in every language. */
|
||||
const KIND_LABELS = {
|
||||
m3u: 'M3U',
|
||||
xtream: 'Xtream',
|
||||
stalker: 'Stalker',
|
||||
} as const;
|
||||
|
||||
function toDate(value: string | number | undefined | null): Date | null {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const date =
|
||||
typeof value === 'number' || /^\d+$/.test(value)
|
||||
? new Date(Number(value))
|
||||
: new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function dateFormat(
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, options);
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en', options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short date in the UI language: day and month, the year only when it is
|
||||
* not the current one, and the time when asked for.
|
||||
*/
|
||||
export function formatSourceDate(
|
||||
value: string | number | undefined | null,
|
||||
locale: string,
|
||||
now: number,
|
||||
withTime = false
|
||||
): string | null {
|
||||
const date = toDate(value);
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
const sameYear = date.getFullYear() === new Date(now).getFullYear();
|
||||
return dateFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** "3 days" / "5 hours" in the UI language; null under an hour. */
|
||||
export function formatSourceDuration(
|
||||
since: number,
|
||||
locale: string,
|
||||
now: number
|
||||
): string | null {
|
||||
const elapsed = now - since;
|
||||
const [unit, amount] =
|
||||
elapsed >= DAY_MS
|
||||
? (['day', Math.floor(elapsed / DAY_MS)] as const)
|
||||
: (['hour', Math.floor(elapsed / HOUR_MS)] as const);
|
||||
if (amount < 1) {
|
||||
return null;
|
||||
}
|
||||
// The app's TypeScript lib (es2018) predates unit formatting; every
|
||||
// supported runtime has it.
|
||||
const options = {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: 'long',
|
||||
} as unknown as Intl.NumberFormatOptions;
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, options).format(amount);
|
||||
} catch {
|
||||
return new Intl.NumberFormat('en', options).format(amount);
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceTypeLabel(
|
||||
playlist: Pick<PlaylistMeta, 'macAddress' | 'serverUrl' | 'url'>,
|
||||
translate: SourceTranslate
|
||||
): string {
|
||||
const kind = resolvePlaylistSourceKind(playlist);
|
||||
if (kind === 'm3u' && !playlist.url) {
|
||||
return translate('WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE');
|
||||
}
|
||||
return KIND_LABELS[kind];
|
||||
}
|
||||
|
||||
export type SourceMetaPartKind =
|
||||
'type' | 'channels' | 'added' | 'updated' | 'url';
|
||||
|
||||
export interface SourceMetaPart {
|
||||
kind: SourceMetaPartKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type SourceMetaFields = SourceDisplayFields &
|
||||
Partial<Pick<PlaylistMeta, 'count' | 'importDate' | 'updateDate'>>;
|
||||
|
||||
/**
|
||||
* The meta line, always in this order: type · channels · added · updated ·
|
||||
* URL, with empty parts left out. Only M3U playlists know their channel
|
||||
* count. The compact switcher row keeps type, channels and updated.
|
||||
*/
|
||||
export function sourceMetaParts(
|
||||
playlist: SourceMetaFields,
|
||||
context: SourceFormatContext,
|
||||
compact = false
|
||||
): SourceMetaPart[] {
|
||||
const { translate, locale, now } = context;
|
||||
const parts: SourceMetaPart[] = [
|
||||
{ kind: 'type', text: sourceTypeLabel(playlist, translate) },
|
||||
];
|
||||
const isM3u = resolvePlaylistSourceKind(playlist) === 'm3u';
|
||||
if (isM3u && (playlist.count ?? 0) > 0) {
|
||||
const count = new Intl.NumberFormat(locale).format(playlist.count ?? 0);
|
||||
parts.push({
|
||||
kind: 'channels',
|
||||
text: translate('HOME.PLAYLISTS.CHANNELS_COUNT', { count }),
|
||||
});
|
||||
}
|
||||
const added = compact
|
||||
? null
|
||||
: formatSourceDate(playlist.importDate, locale, now);
|
||||
if (added) {
|
||||
parts.push({
|
||||
kind: 'added',
|
||||
text: translate('WORKSPACE.SOURCES.ROW.ADDED', { date: added }),
|
||||
});
|
||||
}
|
||||
const updated = formatSourceDate(playlist.updateDate, locale, now, true);
|
||||
if (updated) {
|
||||
parts.push({
|
||||
kind: 'updated',
|
||||
text: translate('WORKSPACE.SOURCES.ROW.UPDATED', { date: updated }),
|
||||
});
|
||||
}
|
||||
const url = compact ? null : sourceDisplayUrl(playlist);
|
||||
if (url) {
|
||||
parts.push({ kind: 'url', text: url });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export interface SourceStatusTag {
|
||||
label: string;
|
||||
tone: 'danger' | 'neutral';
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag after the name. A healthy source has none: green is the dot's
|
||||
* job. The compact row drops the duration and the "not checked" tag.
|
||||
*/
|
||||
export function sourceStatusTag(
|
||||
status: SourceStatus | null,
|
||||
context: SourceFormatContext,
|
||||
compact = false
|
||||
): SourceStatusTag | null {
|
||||
const { translate, locale, now } = context;
|
||||
switch (status?.reachability) {
|
||||
case 'bad': {
|
||||
const duration =
|
||||
!compact && status.failingSince !== undefined
|
||||
? formatSourceDuration(status.failingSince, locale, now)
|
||||
: null;
|
||||
return {
|
||||
tone: 'danger',
|
||||
label: duration
|
||||
? translate('WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR', {
|
||||
duration,
|
||||
})
|
||||
: translate('WORKSPACE.SOURCES.ROW.UNREACHABLE'),
|
||||
};
|
||||
}
|
||||
case 'expired':
|
||||
return {
|
||||
tone: 'danger',
|
||||
label: translate('WORKSPACE.SOURCES.ROW.EXPIRED'),
|
||||
};
|
||||
case 'unknown':
|
||||
return compact
|
||||
? null
|
||||
: {
|
||||
tone: 'neutral',
|
||||
label: translate('SOURCE_HEALTH.REASON.unknown'),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export * from './lib/multi-source';
|
||||
export * from './lib/live-channel-playback-queue';
|
||||
|
||||
export * from './lib/source-health.service';
|
||||
export * from './lib/source-health-history.service';
|
||||
export * from './lib/source-status.service';
|
||||
|
||||
export * from './lib/source-cleanup.service';
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SourceHealthEvidenceService } from '@iptvnator/services';
|
||||
import {
|
||||
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
|
||||
SourceHealthHistoryService,
|
||||
} from './source-health-history.service';
|
||||
|
||||
const failed = (checkedAt: number) => ({
|
||||
state: 'unavailable' as const,
|
||||
reason: 'network' as const,
|
||||
confirmedInactive: false,
|
||||
checkedAt,
|
||||
});
|
||||
|
||||
describe('SourceHealthHistoryService', () => {
|
||||
const create = () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SourceHealthHistoryService,
|
||||
SourceHealthEvidenceService,
|
||||
],
|
||||
});
|
||||
return TestBed.inject(SourceHealthHistoryService);
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('keeps the start of a failing run across a restart', () => {
|
||||
create().record(['a'], failed(100));
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const restarted = create();
|
||||
restarted.record(['a'], failed(900));
|
||||
expect(restarted.entries().get('a')).toEqual({
|
||||
state: 'bad',
|
||||
checkedAt: 900,
|
||||
failingSince: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores no URL or credential', () => {
|
||||
create().record(['a'], failed(100));
|
||||
expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe(
|
||||
'{"a":{"state":"bad","checkedAt":100,"failingSince":100}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores inconclusive checks', () => {
|
||||
const history = create();
|
||||
history.record(['a'], {
|
||||
state: 'unavailable',
|
||||
reason: 'paused',
|
||||
confirmedInactive: false,
|
||||
checkedAt: 100,
|
||||
});
|
||||
expect(history.entries().size).toBe(0);
|
||||
expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('forgets a deleted source and everything on a reset', () => {
|
||||
const history = create();
|
||||
const evidence = TestBed.inject(SourceHealthEvidenceService);
|
||||
history.record(['a', 'b'], failed(100));
|
||||
|
||||
evidence.connections.next({ id: 'a' });
|
||||
expect([...history.entries().keys()]).toEqual(['b']);
|
||||
|
||||
// An edit keeps the entry; the next check replaces it.
|
||||
evidence.connections.next({ id: 'b', playlist: { title: 'b' } });
|
||||
expect([...history.entries().keys()]).toEqual(['b']);
|
||||
|
||||
evidence.connections.next({});
|
||||
expect(history.entries().size).toBe(0);
|
||||
});
|
||||
|
||||
it('starts empty from unreadable storage', () => {
|
||||
localStorage.setItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY, '{nope');
|
||||
expect(create().entries().size).toBe(0);
|
||||
localStorage.setItem(
|
||||
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
|
||||
'{"a":{"state":"weird","checkedAt":1}}'
|
||||
);
|
||||
TestBed.resetTestingModule();
|
||||
expect(create().entries().size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { SourceHealthEvidenceService } from '@iptvnator/services';
|
||||
import {
|
||||
nextSourceHealthHistory,
|
||||
SourceHealthHistoryEntry,
|
||||
SourceHealthSnapshot,
|
||||
sourceReachabilityFromHealth,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const SOURCE_HEALTH_HISTORY_STORAGE_KEY = 'source-health:history:v1';
|
||||
|
||||
function readHistory(): Map<string, SourceHealthHistoryEntry> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return new Map();
|
||||
}
|
||||
return new Map(
|
||||
Object.entries(
|
||||
parsed as Record<string, SourceHealthHistoryEntry>
|
||||
).filter(
|
||||
([, entry]) =>
|
||||
typeof entry?.checkedAt === 'number' &&
|
||||
['ok', 'bad', 'expired'].includes(entry.state)
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(
|
||||
entries: ReadonlyMap<string, SourceHealthHistoryEntry>
|
||||
): Record<string, SourceHealthHistoryEntry> {
|
||||
const record: Record<string, SourceHealthHistoryEntry> = {};
|
||||
entries.forEach((entry, id) => (record[id] = entry));
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last conclusive health result of each source, by playlist ID, kept
|
||||
* across restarts. Live checks stay in memory (`SourceHealthService`); this
|
||||
* keeps only what a list needs before they run again: the state, when it
|
||||
* was checked and since when a source has failed. No URL or credential is
|
||||
* stored, so the entries need no redaction.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceHealthHistoryService {
|
||||
private readonly state =
|
||||
signal<ReadonlyMap<string, SourceHealthHistoryEntry>>(readHistory());
|
||||
readonly entries = this.state.asReadonly();
|
||||
|
||||
constructor() {
|
||||
inject(SourceHealthEvidenceService).connections.subscribe(
|
||||
({ id, playlist }) => {
|
||||
if (!id) {
|
||||
this.write(new Map());
|
||||
} else if (!playlist) {
|
||||
this.forget(id);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
record(ids: readonly string[], snapshot: SourceHealthSnapshot): void {
|
||||
const reachability = sourceReachabilityFromHealth(snapshot);
|
||||
const next = new Map(this.state());
|
||||
let changed = false;
|
||||
for (const id of ids) {
|
||||
const previous = next.get(id);
|
||||
const entry = nextSourceHealthHistory(
|
||||
previous,
|
||||
reachability,
|
||||
snapshot.checkedAt
|
||||
);
|
||||
if (entry && entry !== previous) {
|
||||
next.set(id, entry);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.write(next);
|
||||
}
|
||||
}
|
||||
|
||||
forget(id: string): void {
|
||||
if (!this.state().has(id)) {
|
||||
return;
|
||||
}
|
||||
const next = new Map(this.state());
|
||||
next.delete(id);
|
||||
this.write(next);
|
||||
}
|
||||
|
||||
private write(next: Map<string, SourceHealthHistoryEntry>): void {
|
||||
this.state.set(next);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
|
||||
JSON.stringify(toRecord(next))
|
||||
);
|
||||
} catch {
|
||||
// The history is a convenience; the live checks still work.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
sourceHealthUnknown,
|
||||
sourceHealthError,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SourceHealthHistoryService } from './source-health-history.service';
|
||||
import { SourceHealthProbesService } from './source-health-probes.service';
|
||||
|
||||
interface Job {
|
||||
@@ -32,6 +33,7 @@ interface Job {
|
||||
export class SourceHealthService {
|
||||
private readonly probes = inject(SourceHealthProbesService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly history = inject(SourceHealthHistoryService);
|
||||
readonly snapshots = signal<ReadonlyMap<string, SourceHealthSnapshot>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -301,5 +303,10 @@ export class SourceHealthService {
|
||||
: previous?.lastSuccessAt,
|
||||
};
|
||||
this.snapshots.update((map) => new Map(map).set(key, snapshot));
|
||||
// Every playlist with these connection details shares the result.
|
||||
const ids = [...this.identities]
|
||||
.filter(([, identity]) => identity === key)
|
||||
.map(([id]) => id);
|
||||
if (ids.length) this.history.record(ids, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
PortalStatusService,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
SourceHealthHistoryEntry,
|
||||
SourceHealthSnapshot,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SourceHealthHistoryService } from './source-health-history.service';
|
||||
import { SourceHealthService } from './source-health.service';
|
||||
import { SourceStatusService } from './source-status.service';
|
||||
|
||||
const base = {
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
};
|
||||
const m3u: PlaylistMeta = {
|
||||
...base,
|
||||
_id: 'm',
|
||||
title: 'm',
|
||||
url: 'https://m.test/list',
|
||||
};
|
||||
const local: PlaylistMeta = { ...base, _id: 'l', title: 'l' };
|
||||
const xtream: PlaylistMeta = {
|
||||
...base,
|
||||
_id: 'x',
|
||||
title: 'x',
|
||||
serverUrl: 'http://x.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
|
||||
describe('SourceStatusService', () => {
|
||||
let snapshots: Map<string, SourceHealthSnapshot>;
|
||||
let history: ReturnType<
|
||||
typeof signal<Map<string, SourceHealthHistoryEntry>>
|
||||
>;
|
||||
let health: { get: jest.Mock; check: jest.Mock; recheck: jest.Mock };
|
||||
let portal: {
|
||||
getCachedStatus: jest.Mock;
|
||||
checkPortalStatus: jest.Mock;
|
||||
};
|
||||
|
||||
const create = (supportsSourceHealth: boolean) => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SourceStatusService,
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsSourceHealth },
|
||||
},
|
||||
{ provide: SourceHealthService, useValue: health },
|
||||
{
|
||||
provide: SourceHealthHistoryService,
|
||||
useValue: { entries: history },
|
||||
},
|
||||
{ provide: PortalStatusService, useValue: portal },
|
||||
],
|
||||
});
|
||||
return TestBed.inject(SourceStatusService);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
snapshots = new Map();
|
||||
history = signal(new Map());
|
||||
health = {
|
||||
get: jest.fn((p: PlaylistMeta) => snapshots.get(p._id)),
|
||||
check: jest.fn().mockResolvedValue(undefined),
|
||||
recheck: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
portal = {
|
||||
getCachedStatus: jest.fn().mockReturnValue(null),
|
||||
checkPortalStatus: jest.fn().mockResolvedValue('active'),
|
||||
};
|
||||
});
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
describe('desktop', () => {
|
||||
it('has no status for a local file', () => {
|
||||
expect(create(true).statusOf(local)).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a live result with the kept failing run', () => {
|
||||
snapshots.set('m', {
|
||||
state: 'unavailable',
|
||||
reason: 'network',
|
||||
confirmedInactive: false,
|
||||
checkedAt: 900,
|
||||
});
|
||||
history.set(
|
||||
new Map([
|
||||
['m', { state: 'bad', checkedAt: 900, failingSince: 100 }],
|
||||
])
|
||||
);
|
||||
expect(create(true).statusOf(m3u)).toEqual({
|
||||
reachability: 'bad',
|
||||
failingSince: 100,
|
||||
checkedAt: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the kept result before and while the source is checked', () => {
|
||||
history.set(
|
||||
new Map([
|
||||
['m', { state: 'bad', checkedAt: 50, failingSince: 10 }],
|
||||
])
|
||||
);
|
||||
const service = create(true);
|
||||
expect(service.statusOf(m3u)).toEqual({
|
||||
reachability: 'bad',
|
||||
failingSince: 10,
|
||||
checkedAt: 50,
|
||||
pending: false,
|
||||
});
|
||||
snapshots.set('m', {
|
||||
state: 'checking',
|
||||
reason: 'unknown',
|
||||
confirmedInactive: false,
|
||||
checkedAt: 0,
|
||||
});
|
||||
expect(service.statusOf(m3u)?.pending).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an unchecked source as unknown', () => {
|
||||
expect(create(true).statusOf(m3u)).toEqual({
|
||||
reachability: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates checks to the health service', () => {
|
||||
const service = create(true);
|
||||
const controller = new AbortController();
|
||||
service.ensureChecked(m3u, controller.signal);
|
||||
service.ensureChecked(local, controller.signal);
|
||||
service.recheck(m3u);
|
||||
expect(health.check).toHaveBeenCalledTimes(1);
|
||||
expect(health.check).toHaveBeenCalledWith(m3u, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(health.recheck).toHaveBeenCalledWith(m3u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser build', () => {
|
||||
it('tracks only Xtream accounts', () => {
|
||||
const service = create(false);
|
||||
expect(service.statusOf(m3u)).toBeNull();
|
||||
expect(service.statusOf(xtream)).toEqual({
|
||||
reachability: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows checking, then the portal status', async () => {
|
||||
let resolve!: (status: string) => void;
|
||||
portal.checkPortalStatus.mockReturnValue(
|
||||
new Promise((r) => (resolve = r))
|
||||
);
|
||||
const service = create(false);
|
||||
service.ensureChecked(xtream, new AbortController().signal);
|
||||
expect(service.statusOf(xtream)?.reachability).toBe('checking');
|
||||
resolve('expired');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.statusOf(xtream)?.reachability).toBe('expired');
|
||||
});
|
||||
|
||||
it('serves a cached portal status without a request', () => {
|
||||
portal.getCachedStatus.mockReturnValue('inactive');
|
||||
const service = create(false);
|
||||
service.ensureChecked(xtream, new AbortController().signal);
|
||||
expect(portal.checkPortalStatus).not.toHaveBeenCalled();
|
||||
expect(service.statusOf(xtream)?.reachability).toBe('bad');
|
||||
});
|
||||
|
||||
it('drops a result that lands after the list closed', async () => {
|
||||
const service = create(false);
|
||||
const controller = new AbortController();
|
||||
service.ensureChecked(xtream, controller.signal);
|
||||
controller.abort();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.statusOf(xtream)?.reachability).toBe('unknown');
|
||||
});
|
||||
|
||||
it('bypasses the cache on recheck', () => {
|
||||
create(false).recheck(xtream);
|
||||
expect(portal.checkPortalStatus).toHaveBeenCalledWith(
|
||||
'http://x.test',
|
||||
'u',
|
||||
'p',
|
||||
{ skipCache: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Injectable, Injector, inject, signal } from '@angular/core';
|
||||
import {
|
||||
PortalStatus,
|
||||
PortalStatusService,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
isConclusiveReachability,
|
||||
PlaylistMeta,
|
||||
sourceHealthType,
|
||||
sourceReachabilityFromHealth,
|
||||
sourceReachabilityFromPortalStatus,
|
||||
SourceStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SourceHealthHistoryService } from './source-health-history.service';
|
||||
import { SourceHealthService } from './source-health.service';
|
||||
|
||||
type XtreamAccount = PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function isXtreamAccount(playlist: PlaylistMeta): playlist is XtreamAccount {
|
||||
return !!(playlist.serverUrl && playlist.username && playlist.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* One status per network source for every list that shows one: the Sources
|
||||
* page, its status filter and the playlist switcher.
|
||||
*
|
||||
* - Desktop: the live health check, else the last result kept across
|
||||
* restarts, so a source that failed yesterday is listed as failing before
|
||||
* it is checked again.
|
||||
* - Browser build: the Xtream portal status only, as before; it has no
|
||||
* probes for M3U URLs or Stalker portals.
|
||||
*
|
||||
* Local files have no status (`null`).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceStatusService {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly history = inject(SourceHealthHistoryService);
|
||||
private readonly portalStatus = inject(PortalStatusService);
|
||||
private readonly portalStatuses = signal<ReadonlyMap<string, PortalStatus>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
/** Whether the status of every network source is known (desktop). */
|
||||
get supportsStatusFilter(): boolean {
|
||||
return this.runtime.supportsSourceHealth;
|
||||
}
|
||||
|
||||
tracks(playlist: PlaylistMeta): boolean {
|
||||
return this.runtime.supportsSourceHealth
|
||||
? !!sourceHealthType(playlist)
|
||||
: isXtreamAccount(playlist);
|
||||
}
|
||||
|
||||
statusOf(playlist: PlaylistMeta): SourceStatus | null {
|
||||
if (!this.tracks(playlist)) {
|
||||
return null;
|
||||
}
|
||||
if (!this.runtime.supportsSourceHealth) {
|
||||
const status = this.portalStatuses().get(playlist._id);
|
||||
return {
|
||||
reachability: status
|
||||
? sourceReachabilityFromPortalStatus(status)
|
||||
: 'unknown',
|
||||
};
|
||||
}
|
||||
const live = this.health.get(playlist);
|
||||
const reachability = sourceReachabilityFromHealth(live);
|
||||
const kept = this.history.entries().get(playlist._id);
|
||||
if (isConclusiveReachability(reachability)) {
|
||||
return {
|
||||
reachability,
|
||||
failingSince: kept?.failingSince,
|
||||
checkedAt: live?.checkedAt,
|
||||
};
|
||||
}
|
||||
if (kept) {
|
||||
return {
|
||||
reachability: kept.state,
|
||||
failingSince: kept.failingSince,
|
||||
checkedAt: kept.checkedAt,
|
||||
pending: reachability === 'checking',
|
||||
};
|
||||
}
|
||||
return { reachability };
|
||||
}
|
||||
|
||||
/** A passive check: cached results count; the signal cancels it. */
|
||||
ensureChecked(playlist: PlaylistMeta, signal: AbortSignal): void {
|
||||
if (!this.tracks(playlist)) {
|
||||
return;
|
||||
}
|
||||
if (this.runtime.supportsSourceHealth) {
|
||||
void this.health.check(playlist, { signal });
|
||||
} else if (isXtreamAccount(playlist)) {
|
||||
void this.checkPortal(playlist, false, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Check again" / "Retry": a fresh check that bypasses every cache. */
|
||||
recheck(playlist: PlaylistMeta): void {
|
||||
if (!this.tracks(playlist)) {
|
||||
return;
|
||||
}
|
||||
if (this.runtime.supportsSourceHealth) {
|
||||
void this.health.recheck(playlist);
|
||||
} else if (isXtreamAccount(playlist)) {
|
||||
void this.checkPortal(playlist, true);
|
||||
}
|
||||
}
|
||||
|
||||
private get health(): SourceHealthService {
|
||||
return this.injector.get(SourceHealthService);
|
||||
}
|
||||
|
||||
private async checkPortal(
|
||||
playlist: XtreamAccount,
|
||||
fresh: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const { _id, serverUrl, username, password } = playlist;
|
||||
const cached = fresh
|
||||
? null
|
||||
: this.portalStatus.getCachedStatus(serverUrl, username, password);
|
||||
if (cached) {
|
||||
this.setPortalStatus(_id, cached);
|
||||
return;
|
||||
}
|
||||
if (fresh || !this.portalStatuses().has(_id)) {
|
||||
this.setPortalStatus(_id, 'checking');
|
||||
}
|
||||
let status: PortalStatus;
|
||||
try {
|
||||
status = await this.portalStatus.checkPortalStatus(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
fresh ? { skipCache: true } : undefined
|
||||
);
|
||||
} catch {
|
||||
status = 'unavailable';
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
// A slow portal must not land after the list has closed; drop a
|
||||
// placeholder so the next opening checks again.
|
||||
if (this.portalStatuses().get(_id) === 'checking') {
|
||||
this.setPortalStatus(_id, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.setPortalStatus(_id, status);
|
||||
}
|
||||
|
||||
private setPortalStatus(id: string, status: PortalStatus | null): void {
|
||||
this.portalStatuses.update((current) => {
|
||||
const next = new Map(current);
|
||||
if (status) {
|
||||
next.set(id, status);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -112,4 +112,5 @@ export * from './lib/xtream-connection-test';
|
||||
export * from './lib/portal-request-failure.util';
|
||||
|
||||
export * from './lib/source-health';
|
||||
export * from './lib/source-reachability';
|
||||
export * from './lib/server-storage.contract';
|
||||
@@ -16,7 +16,9 @@ describe('playlistDisplayLabel', () => {
|
||||
// The default :80 is dropped by URL normalization, which reads better
|
||||
// anyway; a non-default port is kept (see the case above).
|
||||
expect(
|
||||
playlistDisplayLabel('http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2')
|
||||
playlistDisplayLabel(
|
||||
'http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2'
|
||||
)
|
||||
).toBe('marveltv.info');
|
||||
});
|
||||
|
||||
@@ -67,7 +69,9 @@ describe('playlistDisplayLabel', () => {
|
||||
describe('playlistNameHasCredentials', () => {
|
||||
it('detects query-string credentials', () => {
|
||||
expect(
|
||||
playlistNameHasCredentials('http://x.tv/get.php?username=a&password=b')
|
||||
playlistNameHasCredentials(
|
||||
'http://x.tv/get.php?username=a&password=b'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -81,4 +85,13 @@ describe('playlistNameHasCredentials', () => {
|
||||
expect(playlistNameHasCredentials('cord-cutter')).toBe(false);
|
||||
expect(playlistNameHasCredentials('http://plain.tv:8080')).toBe(false);
|
||||
});
|
||||
|
||||
it('answers the same for the same name on every call', () => {
|
||||
const name = 'Portal usr== abc pas== def';
|
||||
expect([1, 2, 3].map(() => playlistNameHasCredentials(name))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
/** Query keys whose presence means the string is a credential-bearing URL. */
|
||||
const CREDENTIAL_KEYS = /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
|
||||
const CREDENTIAL_KEYS =
|
||||
/(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
|
||||
|
||||
/** Hand-typed credential fragments, e.g. "usr== Tl24uy0xGi pas== abc". */
|
||||
const INLINE_CREDENTIALS =
|
||||
@@ -56,6 +57,9 @@ export function playlistNameHasCredentials(
|
||||
rawName: string | null | undefined
|
||||
): boolean {
|
||||
const name = rawName ?? '';
|
||||
// INLINE_CREDENTIALS is global for `replace`; `test` on a global pattern
|
||||
// resumes at its lastIndex, so a second call would skip the match.
|
||||
INLINE_CREDENTIALS.lastIndex = 0;
|
||||
return CREDENTIAL_KEYS.test(name) || INLINE_CREDENTIALS.test(name);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
nextSourceHealthHistory,
|
||||
sourceReachabilityFromHealth,
|
||||
sourceReachabilityFromPortalStatus,
|
||||
sourceStatusBucket,
|
||||
} from './source-reachability';
|
||||
|
||||
describe('source reachability', () => {
|
||||
it.each([
|
||||
[{ state: 'active', reason: 'available' }, 'ok'],
|
||||
[{ state: 'expired', reason: 'expired' }, 'expired'],
|
||||
[{ state: 'inactive', reason: 'disabled' }, 'bad'],
|
||||
[{ state: 'inactive', reason: 'auth' }, 'bad'],
|
||||
[{ state: 'checking', reason: 'unknown' }, 'checking'],
|
||||
[{ state: 'unavailable', reason: 'network' }, 'bad'],
|
||||
[{ state: 'unavailable', reason: 'timeout' }, 'bad'],
|
||||
[{ state: 'unavailable', reason: 'http' }, 'bad'],
|
||||
[{ state: 'unavailable', reason: 'invalid' }, 'bad'],
|
||||
// Checks that never reached the source say nothing about it.
|
||||
[{ state: 'unavailable', reason: 'paused' }, 'unknown'],
|
||||
[{ state: 'unavailable', reason: 'cancelled' }, 'unknown'],
|
||||
[{ state: 'unavailable', reason: 'unknown' }, 'unknown'],
|
||||
[{ state: 'unknown', reason: 'unknown' }, 'unknown'],
|
||||
] as const)('maps %o to %s', (snapshot, expected) => {
|
||||
expect(sourceReachabilityFromHealth(snapshot)).toBe(expected);
|
||||
});
|
||||
|
||||
it('treats a source without a snapshot as never checked', () => {
|
||||
expect(sourceReachabilityFromHealth(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['active', 'ok'],
|
||||
['expired', 'expired'],
|
||||
['inactive', 'bad'],
|
||||
['unavailable', 'bad'],
|
||||
['checking', 'checking'],
|
||||
] as const)('maps the portal status %s to %s', (status, expected) => {
|
||||
expect(sourceReachabilityFromPortalStatus(status)).toBe(expected);
|
||||
});
|
||||
|
||||
it('files expired accounts with the sources that do not respond', () => {
|
||||
expect(sourceStatusBucket('ok')).toBe('ok');
|
||||
expect(sourceStatusBucket('bad')).toBe('bad');
|
||||
expect(sourceStatusBucket('expired')).toBe('bad');
|
||||
expect(sourceStatusBucket('checking')).toBeNull();
|
||||
expect(sourceStatusBucket('unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextSourceHealthHistory', () => {
|
||||
it('starts a failing run at the first failed check', () => {
|
||||
expect(nextSourceHealthHistory(undefined, 'bad', 100)).toEqual({
|
||||
state: 'bad',
|
||||
checkedAt: 100,
|
||||
failingSince: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the start of the run across later failures', () => {
|
||||
const first = nextSourceHealthHistory(
|
||||
{ state: 'ok', checkedAt: 50, lastSuccessAt: 50 },
|
||||
'bad',
|
||||
100
|
||||
);
|
||||
const second = nextSourceHealthHistory(first, 'expired', 400);
|
||||
expect(second).toEqual({
|
||||
state: 'expired',
|
||||
checkedAt: 400,
|
||||
failingSince: 100,
|
||||
lastSuccessAt: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('ends the run when the source answers again', () => {
|
||||
expect(
|
||||
nextSourceHealthHistory(
|
||||
{ state: 'bad', checkedAt: 100, failingSince: 10 },
|
||||
'ok',
|
||||
500
|
||||
)
|
||||
).toEqual({ state: 'ok', checkedAt: 500, lastSuccessAt: 500 });
|
||||
});
|
||||
|
||||
it('keeps the previous entry for an inconclusive check', () => {
|
||||
const previous = {
|
||||
state: 'bad',
|
||||
checkedAt: 100,
|
||||
failingSince: 10,
|
||||
} as const;
|
||||
expect(nextSourceHealthHistory(previous, 'unknown', 900)).toBe(
|
||||
previous
|
||||
);
|
||||
expect(nextSourceHealthHistory(previous, 'checking', 900)).toBe(
|
||||
previous
|
||||
);
|
||||
expect(
|
||||
nextSourceHealthHistory(undefined, 'unknown', 900)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { SourceHealthSnapshot } from './source-health';
|
||||
|
||||
/**
|
||||
* What a source list shows about one network source, folded from the six
|
||||
* health states: `ok` answers, `bad` does not, `expired` answers with an
|
||||
* expired account, `checking` is mid-check with nothing known yet, and
|
||||
* `unknown` was never checked or the check was inconclusive.
|
||||
*/
|
||||
export type SourceReachability =
|
||||
'ok' | 'bad' | 'expired' | 'checking' | 'unknown';
|
||||
|
||||
/** A reachability a check actually established. */
|
||||
export type ConclusiveSourceReachability = Extract<
|
||||
SourceReachability,
|
||||
'ok' | 'bad' | 'expired'
|
||||
>;
|
||||
|
||||
/** The two choices of a "responds / does not respond" status filter. */
|
||||
export type SourceStatusBucket = 'ok' | 'bad';
|
||||
|
||||
/** A network source's status as a list shows it. */
|
||||
export interface SourceStatus {
|
||||
reachability: SourceReachability;
|
||||
/** When the current run of failed checks began. */
|
||||
failingSince?: number;
|
||||
checkedAt?: number;
|
||||
/** A check is running; `reachability` is the last known result. */
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health reasons that mean the source itself did not answer properly. A
|
||||
* paused, cancelled or unknown check says nothing about the source.
|
||||
*/
|
||||
const UNREACHABLE_REASONS = new Set(['network', 'timeout', 'http', 'invalid']);
|
||||
|
||||
export function sourceReachabilityFromHealth(
|
||||
snapshot: Pick<SourceHealthSnapshot, 'state' | 'reason'> | undefined
|
||||
): SourceReachability {
|
||||
switch (snapshot?.state) {
|
||||
case 'active':
|
||||
return 'ok';
|
||||
case 'expired':
|
||||
return 'expired';
|
||||
case 'inactive':
|
||||
return 'bad';
|
||||
case 'checking':
|
||||
return 'checking';
|
||||
case 'unavailable':
|
||||
return UNREACHABLE_REASONS.has(snapshot.reason) ? 'bad' : 'unknown';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser build's Xtream portal status, by the same rules. */
|
||||
export function sourceReachabilityFromPortalStatus(
|
||||
status: 'active' | 'inactive' | 'expired' | 'unavailable' | 'checking'
|
||||
): SourceReachability {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'ok';
|
||||
case 'expired':
|
||||
return 'expired';
|
||||
case 'checking':
|
||||
return 'checking';
|
||||
default:
|
||||
return 'bad';
|
||||
}
|
||||
}
|
||||
|
||||
export function isConclusiveReachability(
|
||||
reachability: SourceReachability
|
||||
): reachability is ConclusiveSourceReachability {
|
||||
return (
|
||||
reachability === 'ok' ||
|
||||
reachability === 'bad' ||
|
||||
reachability === 'expired'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expired accounts count as not responding: the source answers, but the
|
||||
* account cannot play anything, which is what the filter is for.
|
||||
*/
|
||||
export function sourceStatusBucket(
|
||||
reachability: SourceReachability
|
||||
): SourceStatusBucket | null {
|
||||
if (reachability === 'ok') {
|
||||
return 'ok';
|
||||
}
|
||||
return reachability === 'bad' || reachability === 'expired' ? 'bad' : null;
|
||||
}
|
||||
|
||||
/** The last conclusive result kept for a source across restarts. */
|
||||
export interface SourceHealthHistoryEntry {
|
||||
state: ConclusiveSourceReachability;
|
||||
checkedAt: number;
|
||||
/** When the current run of failed checks began; absent while `ok`. */
|
||||
failingSince?: number;
|
||||
lastSuccessAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one check into the kept history. An inconclusive check keeps the
|
||||
* previous entry; a failure keeps the start of the current failing run, so
|
||||
* a source that has not answered for three days still says three days after
|
||||
* every later failed check.
|
||||
*/
|
||||
export function nextSourceHealthHistory(
|
||||
previous: SourceHealthHistoryEntry | undefined,
|
||||
reachability: SourceReachability,
|
||||
checkedAt: number
|
||||
): SourceHealthHistoryEntry | undefined {
|
||||
if (!isConclusiveReachability(reachability)) {
|
||||
return previous;
|
||||
}
|
||||
if (reachability === 'ok') {
|
||||
return { state: 'ok', checkedAt, lastSuccessAt: checkedAt };
|
||||
}
|
||||
const wasFailing = previous !== undefined && previous.state !== 'ok';
|
||||
return {
|
||||
state: reachability,
|
||||
checkedAt,
|
||||
failingSince: wasFailing
|
||||
? (previous.failingSince ?? previous.checkedAt)
|
||||
: checkedAt,
|
||||
...(previous?.lastSuccessAt !== undefined
|
||||
? { lastSuccessAt: previous.lastSuccessAt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getPlaylistSourceIcon,
|
||||
resolvePlaylistSourceIconKey,
|
||||
resolvePlaylistSourceKind,
|
||||
SOURCE_TYPE_ICONS,
|
||||
} from './source-type-icons.util';
|
||||
|
||||
@@ -40,4 +41,14 @@ describe('source type icons', () => {
|
||||
SOURCE_TYPE_ICONS[expected]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ macAddress: '00:1A:79:00:00:01', serverUrl: 'http://x' }, 'stalker'],
|
||||
// An Xtream row missing a credential is still Xtream, not M3U: the
|
||||
// old list filter dropped it from every type.
|
||||
[{ serverUrl: 'http://xtream' }, 'xtream'],
|
||||
[{}, 'm3u'],
|
||||
] as const)('puts %o in the %s family', (playlist, expected) => {
|
||||
expect(resolvePlaylistSourceKind(playlist)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -48,3 +48,20 @@ export function getPlaylistSourceIcon(
|
||||
): string {
|
||||
return SOURCE_TYPE_ICONS[resolvePlaylistSourceIconKey(playlist)];
|
||||
}
|
||||
|
||||
/** The provider family a stored playlist belongs to. */
|
||||
export type PlaylistSourceKind = 'm3u' | 'xtream' | 'stalker';
|
||||
|
||||
/**
|
||||
* The provider family by the same precedence as the icon, so every type
|
||||
* filter, count and label agrees with the icon on the row: a playlist
|
||||
* counted as Xtream is also listed and drawn as Xtream.
|
||||
*/
|
||||
export function resolvePlaylistSourceKind(
|
||||
playlist: Pick<PlaylistMeta, 'macAddress' | 'serverUrl'>
|
||||
): PlaylistSourceKind {
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
return playlist.serverUrl ? 'xtream' : 'm3u';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
isSensitiveKey,
|
||||
REDACTED_VALUE,
|
||||
redactSensitiveData,
|
||||
} from './lib/redact-sensitive-data';
|
||||
@@ -24,3 +25,4 @@ export type {
|
||||
RendererPerformancePhaseMetadata,
|
||||
RendererPerformancePhaseOutcome,
|
||||
} from './lib/renderer-performance-phase';
|
||||
export { stripUrlCredentials } from './lib/strip-url-credentials';
|
||||
@@ -54,7 +54,7 @@ const SENSITIVE_KEY_SUFFIXES = [
|
||||
'username',
|
||||
];
|
||||
|
||||
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
|
||||
export const XTREAM_CREDENTIAL_PATH_SEGMENTS: ReadonlySet<string> = new Set([
|
||||
'live',
|
||||
'movie',
|
||||
'series',
|
||||
@@ -79,7 +79,7 @@ function normalizeKey(key: string): string {
|
||||
return key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
const normalized = normalizeKey(key);
|
||||
return (
|
||||
SENSITIVE_KEY_NAMES.has(normalized) ||
|
||||
@@ -196,7 +196,7 @@ function redactEmbeddedSensitivePairs(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeSearchParams(value: string): boolean {
|
||||
export function looksLikeSearchParams(value: string): boolean {
|
||||
return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/u.test(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { stripUrlCredentials } from './strip-url-credentials';
|
||||
|
||||
describe('stripUrlCredentials', () => {
|
||||
it.each([
|
||||
[
|
||||
'http://host.tv:8080/get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
'http://host.tv:8080/get.php?type=m3u_plus',
|
||||
],
|
||||
['http://joe:hunter2@host.tv/list.m3u', 'http://host.tv/list.m3u'],
|
||||
[
|
||||
'http://host.tv/live/joe/hunter2/123.ts',
|
||||
'http://host.tv/live/123.ts',
|
||||
],
|
||||
[
|
||||
'https://cdn.tv/playlist.m3u8?token=abc123&quality=hd',
|
||||
'https://cdn.tv/playlist.m3u8?quality=hd',
|
||||
],
|
||||
['http://portal.tv/c/?mac=00:1A:79:00:00:01', 'http://portal.tv/c/'],
|
||||
[
|
||||
'http://host.tv/list.m3u#user=joe&pass=hunter2',
|
||||
'http://host.tv/list.m3u',
|
||||
],
|
||||
['http://[::1]:3211/get.php?password=p', 'http://[::1]:3211/get.php'],
|
||||
[
|
||||
'https://iptv-org.github.io/iptv/index.m3u',
|
||||
'https://iptv-org.github.io/iptv/index.m3u',
|
||||
],
|
||||
])('shows %s as %s', (input, expected) => {
|
||||
expect(stripUrlCredentials(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('never returns a password from a credential-bearing URL', () => {
|
||||
const urls = [
|
||||
'http://joe:hunter2@host.tv/get.php?username=joe&password=hunter2',
|
||||
'http://host.tv/movie/joe/hunter2/9.mkv?auth=hunter2',
|
||||
'http://host.tv/x#password=hunter2',
|
||||
];
|
||||
for (const url of urls) {
|
||||
expect(stripUrlCredentials(url)).not.toMatch(/hunter2/);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for text that is not an absolute URL', () => {
|
||||
expect(stripUrlCredentials('get.php?username=joe')).toBeNull();
|
||||
expect(stripUrlCredentials('')).toBeNull();
|
||||
expect(stripUrlCredentials('not a url')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
isSensitiveKey,
|
||||
looksLikeSearchParams,
|
||||
XTREAM_CREDENTIAL_PATH_SEGMENTS,
|
||||
} from './redact-sensitive-data';
|
||||
|
||||
/**
|
||||
* Short account keys some playlist URLs use. A log line keeps them for
|
||||
* diagnosis; a list on screen has no reason to.
|
||||
*/
|
||||
const SHORT_ACCOUNT_KEYS = new Set(['user', 'usr', 'pass', 'pas']);
|
||||
|
||||
function isDisplaySensitiveKey(key: string): boolean {
|
||||
return SHORT_ACCOUNT_KEYS.has(key.toLowerCase()) || isSensitiveKey(key);
|
||||
}
|
||||
|
||||
function withoutSensitiveParams(params: URLSearchParams): string {
|
||||
const kept = new URLSearchParams();
|
||||
params.forEach((value, key) => {
|
||||
if (!isDisplaySensitiveKey(key)) {
|
||||
kept.append(key, value);
|
||||
}
|
||||
});
|
||||
return kept.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL safe to show on screen. Where the log redactor replaces secrets with
|
||||
* a marker, this drops them: the userinfo, every query or fragment parameter
|
||||
* the redactor treats as sensitive (`username`, `password`, `token`, `mac`,
|
||||
* …), and the two account segments after `/live/`, `/movie/`, `/series/` or
|
||||
* `/timeshift/` in an Xtream stream path. A list can then say where a source
|
||||
* points without any part of its credentials.
|
||||
*
|
||||
* Returns null for text that is not an absolute URL, so a caller never
|
||||
* renders the unparsed original by mistake.
|
||||
*/
|
||||
export function stripUrlCredentials(value: string): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
|
||||
const segments = url.pathname.split('/');
|
||||
const kept: string[] = [];
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
kept.push(segments[index]);
|
||||
if (
|
||||
XTREAM_CREDENTIAL_PATH_SEGMENTS.has(segments[index]) &&
|
||||
index + 2 < segments.length
|
||||
) {
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
url.pathname = kept.join('/');
|
||||
|
||||
const search = withoutSensitiveParams(url.searchParams);
|
||||
url.search = search ? `?${search}` : '';
|
||||
|
||||
const fragment = url.hash.slice(1);
|
||||
if (looksLikeSearchParams(fragment)) {
|
||||
const hash = withoutSensitiveParams(new URLSearchParams(fragment));
|
||||
url.hash = hash ? `#${hash}` : '';
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Buttons, menus and the segmented control of the IPTVnator UI kit, over
|
||||
// Angular Material: Material keeps the behaviour (focus, keyboard, overlay,
|
||||
// ARIA), these mixins set the look through `mat.*-overrides()`.
|
||||
//
|
||||
// Button variants are roles, not looks:
|
||||
// - `secondary-button` (`mat-flat-button`): every ordinary action and every
|
||||
// dropdown trigger (label + 12px chevron, opening a `mat-menu`);
|
||||
// - `ghost-button` (`mat-button`) and `ghost-icon-button`
|
||||
// (`mat-icon-button`): row and header actions.
|
||||
// No outline, no ripple: hover changes the fill only, and a disabled button
|
||||
// is the same button at 40% opacity. Focus keeps the app's ring
|
||||
// (`styles.scss` rings every `.mat-mdc-button-base`).
|
||||
@use 'sass:map';
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
$radius: 10px;
|
||||
$radius-sm: 8px;
|
||||
|
||||
$_sizes: (
|
||||
md: (
|
||||
height: 40px,
|
||||
radius: $radius,
|
||||
padding: 16px,
|
||||
icon-padding: 14px,
|
||||
text: 13.5px,
|
||||
icon: 16px,
|
||||
gap: 8px,
|
||||
),
|
||||
sm: (
|
||||
height: 32px,
|
||||
radius: $radius-sm,
|
||||
padding: 12px,
|
||||
icon-padding: 10px,
|
||||
text: 12.5px,
|
||||
icon: 14px,
|
||||
gap: 6px,
|
||||
),
|
||||
);
|
||||
|
||||
@function _size($size, $key) {
|
||||
@return map.get($_sizes, $size, $key);
|
||||
}
|
||||
|
||||
@mixin _icon-size($size) {
|
||||
.mat-icon {
|
||||
width: $size;
|
||||
height: $size;
|
||||
font-size: $size;
|
||||
line-height: $size;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin _disabled {
|
||||
&[disabled],
|
||||
&.mat-mdc-button-disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/// `$size`: `md` (40px) or `sm` (32px).
|
||||
@mixin secondary-button($size: md) {
|
||||
$fill: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
filled-container-color: $fill,
|
||||
filled-disabled-container-color: $fill,
|
||||
filled-label-text-color: var(--app-heading-color),
|
||||
filled-disabled-label-text-color: var(--app-heading-color),
|
||||
filled-container-height: _size($size, height),
|
||||
filled-container-shape: _size($size, radius),
|
||||
filled-horizontal-padding: _size($size, padding),
|
||||
filled-label-text-size: _size($size, text),
|
||||
filled-label-text-weight: 600,
|
||||
filled-label-text-tracking: 0,
|
||||
filled-icon-spacing: _size($size, gap),
|
||||
filled-state-layer-color: var(--app-on-surface),
|
||||
filled-hover-state-layer-opacity: 0.06,
|
||||
filled-focus-state-layer-opacity: 0.06,
|
||||
filled-pressed-state-layer-opacity: 0.1,
|
||||
filled-ripple-color: transparent,
|
||||
)
|
||||
);
|
||||
@include _icon-size(_size($size, icon));
|
||||
@include _disabled;
|
||||
}
|
||||
|
||||
/// A text action: `--app-body-color`, the heading colour on hover.
|
||||
@mixin ghost-button($size: sm) {
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
text-label-text-color: var(--app-body-color),
|
||||
text-disabled-label-text-color: var(--app-body-color),
|
||||
text-container-height: _size($size, height),
|
||||
text-container-shape: _size($size, radius),
|
||||
text-horizontal-padding: _size($size, padding),
|
||||
text-with-icon-horizontal-padding: _size($size, icon-padding),
|
||||
text-label-text-size: _size($size, text),
|
||||
text-label-text-weight: 600,
|
||||
text-label-text-tracking: 0,
|
||||
text-icon-spacing: 6px,
|
||||
text-state-layer-color: var(--app-on-surface),
|
||||
text-hover-state-layer-opacity: 0.08,
|
||||
text-focus-state-layer-opacity: 0.08,
|
||||
text-pressed-state-layer-opacity: 0.12,
|
||||
text-ripple-color: transparent,
|
||||
)
|
||||
);
|
||||
@include _icon-size(14px);
|
||||
@include _disabled;
|
||||
|
||||
&:not([disabled]):hover {
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
text-label-text-color: var(--app-heading-color),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A square icon action with an 8px radius, not Material's circle.
|
||||
@mixin ghost-icon-button($size: 32px, $icon-size: 16px) {
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
state-layer-size: $size,
|
||||
container-shape: $radius-sm,
|
||||
icon-size: $icon-size,
|
||||
icon-color: var(--app-body-color),
|
||||
disabled-icon-color: var(--app-body-color),
|
||||
state-layer-color: var(--app-on-surface),
|
||||
hover-state-layer-opacity: 0.08,
|
||||
focus-state-layer-opacity: 0.08,
|
||||
pressed-state-layer-opacity: 0.12,
|
||||
ripple-color: transparent,
|
||||
touch-target-display: none,
|
||||
)
|
||||
);
|
||||
border-radius: $radius-sm;
|
||||
@include _icon-size($icon-size);
|
||||
@include _disabled;
|
||||
|
||||
&:not([disabled]):hover {
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
icon-color: var(--app-heading-color),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The trailing 12px chevron of a dropdown trigger.
|
||||
@mixin trigger-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
margin-inline-start: 2px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
/// A `mat-menu` panel; apply to the class given to `<mat-menu class>`.
|
||||
/// Items: 36px, 13px, 16px icons in the body colour; destructive items
|
||||
/// carry `app-menu-item--danger` and sit after a divider; a keyboard hint
|
||||
/// is a trailing `kbd.app-menu-item__kbd`.
|
||||
@mixin menu-panel {
|
||||
@include mat.menu-overrides(
|
||||
(
|
||||
container-color: var(--app-widget-bg),
|
||||
container-shape: $radius,
|
||||
item-label-text-color: var(--app-heading-color),
|
||||
item-label-text-size: 13px,
|
||||
item-label-text-weight: 400,
|
||||
item-icon-color: var(--app-body-color),
|
||||
item-icon-size: 16px,
|
||||
item-hover-state-layer-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 8%,
|
||||
transparent
|
||||
),
|
||||
item-focus-state-layer-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
),
|
||||
item-leading-spacing: 10px,
|
||||
item-trailing-spacing: 10px,
|
||||
item-with-icon-leading-spacing: 10px,
|
||||
item-with-icon-trailing-spacing: 10px,
|
||||
item-spacing: 10px,
|
||||
divider-color: var(--app-separator),
|
||||
divider-top-spacing: 4px,
|
||||
divider-bottom-spacing: 4px,
|
||||
)
|
||||
);
|
||||
min-width: 240px;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.mat-mdc-menu-item {
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mat-mdc-menu-item[aria-checked='true'] {
|
||||
background: var(--app-list-selected-bg);
|
||||
color: var(--app-list-selected-fg);
|
||||
|
||||
.mat-icon {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.app-menu-item--danger,
|
||||
.app-menu-item--danger .mat-icon {
|
||||
color: var(--app-danger-text);
|
||||
}
|
||||
|
||||
.app-menu-item__kbd {
|
||||
margin-inline-start: auto;
|
||||
padding-inline-start: 16px;
|
||||
font-family: var(--app-font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
}
|
||||
|
||||
/// `mat-button-toggle-group` as a segmented control: a recessed track, no
|
||||
/// dividers, the chosen segment filled.
|
||||
@mixin segmented-control($height: 30px) {
|
||||
@include mat.button-toggle-overrides(
|
||||
(
|
||||
height: $height,
|
||||
shape: 10px,
|
||||
divider-color: transparent,
|
||||
background-color: transparent,
|
||||
text-color: var(--app-muted-color),
|
||||
selected-state-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
),
|
||||
selected-state-text-color: var(--app-heading-color),
|
||||
state-layer-color: var(--app-on-surface),
|
||||
hover-state-layer-opacity: 0.06,
|
||||
focus-state-layer-opacity: 0.06,
|
||||
label-text-size: 12.5px,
|
||||
label-text-weight: 500,
|
||||
label-text-tracking: 0,
|
||||
)
|
||||
);
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
background: var(--app-content-bg);
|
||||
|
||||
.mat-button-toggle {
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// The group already draws its frame; Material adds a left border per
|
||||
// toggle as the divider.
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
.mat-button-toggle-label-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.mat-pseudo-checkbox {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,5 @@
|
||||
@forward 'detail-view-actions';
|
||||
@forward 'nav-list';
|
||||
@forward 'focus-ring';
|
||||
@forward 'app-controls' as controls-*;
|
||||
@forward 'skeleton-shimmer' as skeleton-*;
|
||||
Reference in new issue
Block a user