From d61fd5db19684a473598a570cf9dbe4ca15c2c5a Mon Sep 17 00:00:00 2001 From: Stefan Date: Sat, 18 Jul 2026 15:43:37 +0200 Subject: [PATCH] feat: add strip country prefix setting (#1162) * feat: add strip country prefix setting * feat: scope country-prefix stripping to live content and cover missing surfaces - narrow the heuristic: pipes always strip, dash/colon separators only when the prefix is a short uppercase tag ("UK - BBC One" strips, "Sky - Sports F1" and "Mission: Impossible - Fallout" stay intact) - fall back to the original name when stripping would leave nothing - scope stripping to live content only: grid type (live/itv/radio), playback isLive, external sessions without contentInfo, dashboard cards with contentType 'live' - cover previously missed surfaces: M3U player EPG timeline header, M3U inline player title, radio player, dashboard live rails - replace hardcoded settings strings with translate keys and add SETTINGS.STRIP_COUNTRY_PREFIX(_DESCRIPTION) to all 18 locales - add unit specs for the utility plus regression specs for channel-list-item and external-playback-dock Co-Authored-By: Claude Fable 5 * test: cover strip-country-prefix call sites for codecov - dashboard-rail: new spec for cardTitle live/movie/series scoping - grid-list: strip enabled/disabled, VOD passthrough, 'No name' fallback - portal-inline-player: live strip vs VOD passthrough - unified-live-tab: timeline channel name strip + M3U name precedence - video-player: timeline/radio/inline titles with the setting on and off - settings-store: default false + persisted true round-trip - settings-form.utils: new spec for form default and ?? false fallback Co-Authored-By: Claude Fable 5 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5 --- .../app/settings/settings-form.utils.spec.ts | 36 ++++++ .../src/app/settings/settings-form.utils.ts | 2 + .../settings-general-section.component.html | 12 ++ .../app/settings/settings.component.spec.ts | 1 + apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + .../video-player/video-player.component.html | 6 +- .../video-player.component.spec.ts | 23 ++++ .../video-player/video-player.component.ts | 23 +++- .../category-content-view.component.html | 1 + .../category-content-view.component.spec.ts | 1 + .../grid-list/grid-list.component.spec.ts | 76 +++++++++++++ .../grid-list/grid-list.component.ts | 19 +++- .../unified-live-tab.component.spec.ts | 32 ++++++ .../unified-live-tab.component.ts | 10 +- .../src/lib/settings-store.service.spec.ts | 20 ++++ .../src/lib/settings-store.service.ts | 4 + .../interfaces/src/lib/settings.interface.ts | 2 + libs/shared/m3u-utils/src/index.ts | 1 + .../src/lib/strip-country-prefix.util.spec.ts | 107 ++++++++++++++++++ .../src/lib/strip-country-prefix.util.ts | 79 +++++++++++++ .../channel-list-item.component.html | 2 +- .../channel-list-item.component.spec.ts | 46 ++++++++ .../channel-list-item.component.ts | 10 ++ .../external-playback-dock.component.html | 2 +- .../external-playback-dock.component.spec.ts | 60 ++++++++++ .../external-playback-dock.component.ts | 18 +++ .../portal-inline-player.component.spec.ts | 54 ++++++++- .../portal-inline-player.component.ts | 14 ++- .../lib/rails/dashboard-rail.component.html | 12 +- .../rails/dashboard-rail.component.spec.ts | 72 ++++++++++++ .../src/lib/rails/dashboard-rail.component.ts | 14 +++ 48 files changed, 773 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/app/settings/settings-form.utils.spec.ts create mode 100644 libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts create mode 100644 libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts diff --git a/apps/web/src/app/settings/settings-form.utils.spec.ts b/apps/web/src/app/settings/settings-form.utils.spec.ts new file mode 100644 index 000000000..68b741725 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.utils.spec.ts @@ -0,0 +1,36 @@ +import { FormBuilder } from '@angular/forms'; +import { Settings } from '@iptvnator/shared/interfaces'; +import { + createSettingsForm, + createSettingsFromFormValue, +} from './settings-form.utils'; + +describe('settings form utils — strip country prefix', () => { + const formBuilder = new FormBuilder(); + + it('defaults the form control to false', () => { + const form = createSettingsForm(formBuilder, true); + + expect(form.getRawValue().stripCountryPrefix).toBe(false); + }); + + it('carries an enabled toggle into the settings object', () => { + const form = createSettingsForm(formBuilder, true); + form.patchValue({ stripCountryPrefix: true }); + + const settings = createSettingsFromFormValue(form, {} as Settings); + + expect(settings.stripCountryPrefix).toBe(true); + }); + + it('falls back to false when the form value is missing', () => { + const form = createSettingsForm(formBuilder, true); + form.patchValue({ + stripCountryPrefix: null as unknown as boolean, + }); + + const settings = createSettingsFromFormValue(form, {} as Settings); + + expect(settings.stripCountryPrefix).toBe(false); + }); +}); diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index f09c4aa6e..47ec38bb9 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -55,6 +55,7 @@ export function createSettingsForm( }), startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, + stripCountryPrefix: false, theme: Theme.SystemTheme, mpvPlayerPath: '', mpvPlayerArguments: '', @@ -124,6 +125,7 @@ export function createSettingsFromFormValue( dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails), startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView, showExternalPlaybackBar: value.showExternalPlaybackBar ?? true, + stripCountryPrefix: value.stripCountryPrefix ?? false, theme: value.theme ?? Theme.SystemTheme, mpvPlayerPath: normalizeExternalPlayerPath(value.mpvPlayerPath), mpvPlayerArguments: normalizeExternalPlayerArguments( diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html index a234e1b66..dda4b45b5 100644 --- a/apps/web/src/app/settings/settings-general-section.component.html +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -156,4 +156,16 @@ + +
+
+

{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}

+

+ {{ 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' | translate }} +

+
+
+ +
+
diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index d4ac9ac93..86c86339f 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -92,6 +92,7 @@ const DEFAULT_SETTINGS = { showDashboard: true, startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, + stripCountryPrefix: false, theme: Theme.SystemTheme, mpvPlayerPath: '', mpvPlayerArguments: '', diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d1ff0c9ba..467918a64 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "عرض أو إخفاء الترجمات افتراضيًا", "SHOW_EXTERNAL_PLAYBACK_BAR": "عرض شريط التشغيل الحالي", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "إبقاء شريط حالة المشغل الخارجي مرئيًا في أسفل مساحة العمل على سطح المكتب.", + "STRIP_COUNTRY_PREFIX": "إزالة بادئات البلد من أسماء القنوات", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "يزيل بادئات البلد/المجموعة مثل \"US | \" أو \"UK - \" من أسماء قنوات البث المباشر في جميع أنحاء التطبيق.", "VERSION_DESCRIPTION": "الإصدار الحالي للتطبيق", "ABOUT_SUBTITLE": "الإصدار وروابط المشروع وطرق دعم IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "بيئة تشغيل MPV المضمّنة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ccd51471c..1338c3290 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "وري ولا خفي الترجمة افتراضياً", "SHOW_EXTERNAL_PLAYBACK_BAR": "وري شريط التشغيل الحالي", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "خلي شريط حالة المشغل الخارجي ظاهر في الأسفل ديال مساحة العمل على سطح المكتب.", + "STRIP_COUNTRY_PREFIX": "حيد بادئات البلد من أسماء القنوات", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "كيحيد بادئات البلد/المجموعة بحال \"US | \" ولا \"UK - \" من أسماء قنوات البث المباشر فكامل التطبيق.", "VERSION_DESCRIPTION": "الإصدار الحالي ديال التطبيق", "ABOUT_SUBTITLE": "الإصدار، روابط المشروع، وطرق دعم IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "وقت تشغيل MPV المدمج", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 3c8433486..cd7ffc4be 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Паказаць або схаваць субтытры па змаўчанні.", "SHOW_EXTERNAL_PLAYBACK_BAR": "Паказаць ніжнюю панэль прайгравання", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Пакідаць панэль стану знешняга прайгравальніка бачнай унізе працоўнай прасторы на дэсктопе.", + "STRIP_COUNTRY_PREFIX": "Выдаляць прэфіксы краін з назваў каналаў", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Выдаляе прэфіксы краін/груп, такія як \"US | \" або \"UK - \", з назваў каналаў прамых трансляцый ва ўсёй праграме.", "VERSION_DESCRIPTION": "Бягучая версія праграмы", "ABOUT_SUBTITLE": "Версія, спасылкі праекта і спосабы падтрымаць IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Асяроддзе ўбудаванага MPV", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 313309af8..dfa4d3cfb 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden", "SHOW_EXTERNAL_PLAYBACK_BAR": "Now-Playing-Fußleiste anzeigen", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Zeigt die Statusleiste für externe Player unten im Arbeitsbereich auf dem Desktop an.", + "STRIP_COUNTRY_PREFIX": "Länderpräfixe aus Sendernamen entfernen", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Entfernt Länder-/Gruppenpräfixe wie \"US | \" oder \"UK - \" aus den Namen von Live-Sendern in der gesamten App.", "VERSION_DESCRIPTION": "Aktuelle Version der Anwendung", "ABOUT_SUBTITLE": "Version, Projekt-Links und Möglichkeiten, IPTVnator zu unterstützen.", "EMBEDDED_MPV_NOTICE_TITLE": "Eingebettete MPV-Laufzeit", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index afc38a1f4..91983632d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Εμφάνιση ή απόκρυψη υποτίτλων από προεπιλογή", "SHOW_EXTERNAL_PLAYBACK_BAR": "Εμφάνιση υποσέλιδου αναπαραγωγής", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Διατηρήστε ορατή τη γραμμή κατάστασης του εξωτερικού προγράμματος αναπαραγωγής στο κάτω μέρος του χώρου εργασίας στον υπολογιστή.", + "STRIP_COUNTRY_PREFIX": "Αφαίρεση προθεμάτων χώρας από τα ονόματα καναλιών", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Αφαιρεί προθέματα χώρας/ομάδας όπως \"US | \" ή \"UK - \" από τα ονόματα καναλιών ζωντανής ροής σε όλη την εφαρμογή.", "VERSION_DESCRIPTION": "Τρέχουσα έκδοση της εφαρμογής", "ABOUT_SUBTITLE": "Έκδοση, σύνδεσμοι έργου και τρόποι υποστήριξης του IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Ενσωματωμένος χρόνος εκτέλεσης MPV", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 5f33f3935..937ff2f39 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default", "SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.", + "STRIP_COUNTRY_PREFIX": "Strip country prefixes from channel names", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Removes country/group prefixes like \"US | \" or \"UK - \" from live channel names throughout the app.", "VERSION_DESCRIPTION": "Current version of the application", "ABOUT_SUBTITLE": "Version, project links and ways to support IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Embedded MPV runtime", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c05d3ffb7..8be4a70a5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Mostrar u ocultar subtítulos por defecto", "SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar pie de reproducción actual", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantén visible la barra de estado del reproductor externo en la parte inferior del espacio de trabajo en escritorio.", + "STRIP_COUNTRY_PREFIX": "Quitar prefijos de país de los nombres de los canales", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Elimina los prefijos de país o grupo, como \"US | \" o \"UK - \", de los nombres de los canales en vivo en toda la app.", "VERSION_DESCRIPTION": "Versión actual de la aplicación.", "ABOUT_SUBTITLE": "Versión, enlaces del proyecto y formas de apoyar a IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime de MPV integrado", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index f98222115..0b1bc23b6 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Afficher ou masquer les sous-titres par défaut", "SHOW_EXTERNAL_PLAYBACK_BAR": "Afficher le pied de page de lecture en cours", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Garde la barre d'état du lecteur externe visible en bas de l'espace de travail sur ordinateur.", + "STRIP_COUNTRY_PREFIX": "Retirer les préfixes de pays des noms de chaînes", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Supprime les préfixes de pays ou de groupe comme \"US | \" ou \"UK - \" des noms des chaînes en direct dans toute l'application.", "VERSION_DESCRIPTION": "Version actuelle de l'application", "ABOUT_SUBTITLE": "Version, liens du projet et façons de soutenir IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV intégré", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 13f846f00..328aa8be3 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Visualizza/nascondi i sottotitoli per impostazione predefinita", "SHOW_EXTERNAL_PLAYBACK_BAR": "Mostra footer In riproduzione", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantieni visibile la barra di stato del lettore esterno in fondo all'area di lavoro su desktop.", + "STRIP_COUNTRY_PREFIX": "Rimuovi i prefissi del paese dai nomi dei canali", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Rimuove i prefissi di paese/gruppo come \"US | \" o \"UK - \" dai nomi dei canali live in tutta l'app.", "VERSION_DESCRIPTION": "Versione attuale dell'applicazione", "ABOUT_SUBTITLE": "Versione, link al progetto e modi per supportare IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrato", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index aa8e59c32..aced7de92 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "デフォルトで字幕を表示/非表示", "SHOW_EXTERNAL_PLAYBACK_BAR": "再生中フッターを表示", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "デスクトップでワークスペース下部に外部プレーヤーのステータスバーを表示し続けます。", + "STRIP_COUNTRY_PREFIX": "チャンネル名から国名プレフィックスを削除", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "アプリ全体で、ライブチャンネル名から \"US | \" や \"UK - \" のような国/グループのプレフィックスを削除します。", "VERSION_DESCRIPTION": "アプリケーションの現在のバージョン", "ABOUT_SUBTITLE": "バージョン、プロジェクトのリンク、IPTVnatorをサポートする方法。", "EMBEDDED_MPV_NOTICE_TITLE": "組み込みMPVランタイム", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index fb8a6a661..a0d4bef59 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "기본적으로 자막 표시 또는 숨기기", "SHOW_EXTERNAL_PLAYBACK_BAR": "지금 재생 중 표시줄 표시", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "데스크톱의 작업 공간 하단에 외부 플레이어 상태 표시줄을 계속 표시합니다.", + "STRIP_COUNTRY_PREFIX": "채널 이름에서 국가 접두사 제거", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "라이브 채널 이름에서 \"US | \" 또는 \"UK - \"와 같은 국가/그룹 접두사를 앱 전체에서 제거합니다.", "VERSION_DESCRIPTION": "애플리케이션의 현재 버전", "ABOUT_SUBTITLE": "버전, 프로젝트 링크 및 IPTVnator를 후원하는 방법.", "EMBEDDED_MPV_NOTICE_TITLE": "내장 MPV 런타임", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 153a6e6ae..8ab9803ac 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Standaard ondertiteling weergeven of verbergen", "SHOW_EXTERNAL_PLAYBACK_BAR": "Voettekst 'nu spelend' tonen", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Houd de statusbalk van de externe speler zichtbaar onderaan de werkruimte op desktop.", + "STRIP_COUNTRY_PREFIX": "Landprefixen uit kanaalnamen verwijderen", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Verwijdert land-/groepsprefixen zoals \"US | \" of \"UK - \" uit de namen van live-kanalen, overal in de app.", "VERSION_DESCRIPTION": "Huidige versie van de applicatie", "ABOUT_SUBTITLE": "Versie, projectlinks en manieren om IPTVnator te ondersteunen.", "EMBEDDED_MPV_NOTICE_TITLE": "Ingebedde MPV-runtime", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 32d8608d6..567ca6a63 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Domyślnie pokaż lub ukryj napisy", "SHOW_EXTERNAL_PLAYBACK_BAR": "Pokaż stopkę „teraz odtwarzane”", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Pozostaw pasek statusu zewnętrznego odtwarzacza widoczny u dołu obszaru roboczego na komputerze.", + "STRIP_COUNTRY_PREFIX": "Usuń prefiksy krajów z nazw kanałów", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Usuwa prefiksy kraju/grupy, takie jak \"US | \" lub \"UK - \", z nazw kanałów na żywo w całej aplikacji.", "VERSION_DESCRIPTION": "Aktualna wersja aplikacji", "ABOUT_SUBTITLE": "Wersja, linki do projektu i sposoby wsparcia IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Środowisko wbudowanego MPV", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 861f772e9..45b14ebaf 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Mostrar ou ocultar legendas por padrão", "SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar rodapé de reprodução atual", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantém a barra de status do reprodutor externo visível na parte inferior do espaço de trabalho no desktop.", + "STRIP_COUNTRY_PREFIX": "Remover prefixos de país dos nomes dos canais", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Remove prefixos de país/grupo como \"US | \" ou \"UK - \" dos nomes dos canais ao vivo em todo o aplicativo.", "VERSION_DESCRIPTION": "Versão atual do aplicativo", "ABOUT_SUBTITLE": "Versão, links do projeto e formas de apoiar o IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrado", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index d84865bde..5b1af1d62 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.", "SHOW_EXTERNAL_PLAYBACK_BAR": "Показывать панель «Сейчас воспроизводится»", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Оставлять панель состояния внешнего проигрывателя видимой внизу рабочей области в настольном приложении.", + "STRIP_COUNTRY_PREFIX": "Удалять префиксы стран из названий каналов", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Удаляет префиксы стран/групп, такие как \"US | \" или \"UK - \", из названий каналов прямого эфира во всём приложении.", "VERSION_DESCRIPTION": "Текущая версия приложения", "ABOUT_SUBTITLE": "Версия, ссылки на проект и способы поддержать IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Среда выполнения встроенного MPV", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6f0d14338..677f6f26c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "Varsayılan olarak altyazıları göster veya gizle", "SHOW_EXTERNAL_PLAYBACK_BAR": "Şu an oynatılıyor altbilgisini göster", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Masaüstünde harici oynatıcı durum çubuğunu çalışma alanının altında görünür tutun.", + "STRIP_COUNTRY_PREFIX": "Kanal adlarından ülke ön eklerini kaldır", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "Uygulama genelinde canlı kanal adlarından \"US | \" veya \"UK - \" gibi ülke/grup ön eklerini kaldırır.", "VERSION_DESCRIPTION": "Uygulamanın geçerli sürümü", "ABOUT_SUBTITLE": "Sürüm, proje bağlantıları ve IPTVnator'u desteklemenin yolları.", "EMBEDDED_MPV_NOTICE_TITLE": "Gömülü MPV çalışma zamanı", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 06f0bda98..2324e24d0 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "默认显示或隐藏字幕", "SHOW_EXTERNAL_PLAYBACK_BAR": "显示「正在播放」底栏", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面端工作区底部保持外部播放器状态栏可见。", + "STRIP_COUNTRY_PREFIX": "移除频道名称中的国家前缀", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "从整个应用中的直播频道名称里移除国家/分组前缀,例如 \"US | \" 或 \"UK - \"。", "VERSION_DESCRIPTION": "应用程序的当前版本", "ABOUT_SUBTITLE": "版本、项目链接以及支持 IPTVnator 的方式。", "EMBEDDED_MPV_NOTICE_TITLE": "嵌入式 MPV 运行时", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 7d7653315..f3555e659 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -317,6 +317,8 @@ "SHOW_CAPTIONS_DESCRIPTION": "預設顯示或隱藏字幕", "SHOW_EXTERNAL_PLAYBACK_BAR": "顯示「正在播放」列", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面工作區底部保留外部播放器狀態列可見。", + "STRIP_COUNTRY_PREFIX": "從頻道名稱中移除國家前綴", + "STRIP_COUNTRY_PREFIX_DESCRIPTION": "移除應用程式中直播頻道名稱裡的國家/群組前綴,例如「US | 」或「UK - 」。", "VERSION_DESCRIPTION": "應用程式目前版本", "ABOUT_SUBTITLE": "版本、專案連結,以及支持 IPTVnator 的方式。", "EMBEDDED_MPV_NOTICE_TITLE": "內嵌 MPV 執行環境", diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 4d9d98dd8..aa4b12ff0 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -67,16 +67,14 @@ } @else { { const player = signal(VideoPlayer.VideoJs); const showCaptions = signal(false); + const stripCountryPrefix = signal(false); const epgViewMode = signal<'timeline' | 'list'>('timeline'); const originalElectron = window.electron; @@ -314,6 +315,7 @@ describe('VideoPlayerComponent', () => { localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); player.set(VideoPlayer.VideoJs); showCaptions.set(false); + stripCountryPrefix.set(false); activePlaybackUrl.set(null); channelsLoading.set(false); currentEpgProgram.set(null); @@ -389,6 +391,7 @@ describe('VideoPlayerComponent', () => { useValue: { player, showCaptions, + stripCountryPrefix, resolvedEpgViewMode: epgViewMode, }, }, @@ -456,6 +459,26 @@ describe('VideoPlayerComponent', () => { expect(headerContext.action()).toBeNull(); }); + it('strips country prefixes from the timeline and player titles when enabled', () => { + stripCountryPrefix.set(true); + syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel); + + fixture.detectChanges(); + + expect(component.timelineChannelName()).toBe('CNN'); + expect(component.displayChannelName()).toBe('CNN'); + expect(component.inlinePlayerTitle()).toBe('CNN'); + }); + + it('keeps raw channel names while prefix stripping is disabled', () => { + syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel); + + fixture.detectChanges(); + + expect(component.timelineChannelName()).toBe('US | CNN'); + expect(component.displayChannelName()).toBe('US | CNN'); + }); + it('renders the inline player with the embedded EPG panel', () => { syncStoreState(sampleChannel); player.set(VideoPlayer.VideoJs); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index e1dbf329b..e7513d6b2 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -22,6 +22,7 @@ import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe } from '@ngx-translate/core'; import { ResizableDirective } from '@iptvnator/ui/components'; import { + applyChannelNameStrip, getM3uArchiveDays, isM3uCatchupPlaybackSupported, } from '@iptvnator/shared/m3u-utils'; @@ -182,8 +183,26 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly epgArchiveDays = computed(() => getM3uArchiveDays(this.activeChannel()) ); - readonly timelineChannelName = computed( - () => this.activeChannel()?.name ?? '' + readonly timelineChannelName = computed(() => + applyChannelNameStrip( + this.activeChannel()?.name, + this.settingsStore.stripCountryPrefix?.() + ) + ); + /** Channel name for the radio player header. */ + readonly displayChannelName = computed(() => { + const channel = this.activeChannel(); + return applyChannelNameStrip( + channel?.name || channel?.tvg?.name, + this.settingsStore.stripCountryPrefix?.() + ); + }); + /** Display title for the inline web player header. */ + readonly inlinePlayerTitle = computed(() => + applyChannelNameStrip( + this.embeddedPlayback()?.title, + this.settingsStore.stripCountryPrefix?.() + ) ); /** Channel logo from the EPG feed (M3U playlists often lack tvg-logo). */ private readonly epgChannelLogo = toSignal( diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index 96ad8756c..6763eb864 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -211,6 +211,7 @@ [pageSizeOptions]="pageSizeOptions" [showPaginator]="false" [searchTerm]="searchTerm()" + [type]="contentType() ?? ''" (pageChange)="onPageChange($event)" /> diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index a5acd656a..e41e7c60b 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -31,6 +31,7 @@ class MockGridListComponent { readonly pageSizeOptions = input(); readonly showPaginator = input(true); readonly searchTerm = input(''); + readonly type = input(''); readonly itemClicked = output(); readonly pageChange = output(); } diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts index 975cfb4bf..20ccfcb16 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts @@ -1,7 +1,9 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslatePipe } from '@ngx-translate/core'; import { MockPipe } from 'ng-mocks'; +import { SettingsStore } from '@iptvnator/services'; import { formatGridRating, GridListComponent, @@ -159,4 +161,78 @@ describe('GridListComponent', () => { 'live_tv' ); }); + + it('renders raw titles while prefix stripping is disabled', () => { + fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]); + fixture.componentRef.setInput('type', 'live'); + + fixture.detectChanges(); + + const title = fixture.debugElement.query(By.css('.title')); + expect(title.nativeElement.textContent.trim()).toBe('US | CNN'); + }); + + it('falls back to a placeholder title for items without a name', () => { + fixture.componentRef.setInput('items', [{}]); + + fixture.detectChanges(); + + const title = fixture.debugElement.query(By.css('.title')); + expect(title.nativeElement.textContent.trim()).toBe('No name'); + }); +}); + +describe('GridListComponent with strip country prefix enabled', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GridListComponent], + providers: [ + { + provide: SettingsStore, + useValue: { stripCountryPrefix: signal(true) }, + }, + ], + }) + .overrideComponent(GridListComponent, { + remove: { imports: [TranslatePipe] }, + add: { + imports: [ + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(GridListComponent); + }); + + const renderedTitle = () => + fixture.debugElement + .query(By.css('.title')) + .nativeElement.textContent.trim(); + + it('strips prefixes from live grid titles', () => { + fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]); + fixture.componentRef.setInput('type', 'live'); + + fixture.detectChanges(); + + expect(renderedTitle()).toBe('CNN'); + }); + + it('keeps VOD titles untouched', () => { + fixture.componentRef.setInput('items', [ + { title: 'US | Some Movie' }, + ]); + fixture.componentRef.setInput('type', 'vod'); + + fixture.detectChanges(); + + expect(renderedTitle()).toBe('US | Some Movie'); + }); }); diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index bee1c3a1b..1b1d59565 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + inject, input, output, signal, @@ -11,6 +12,8 @@ import { MatIcon } from '@angular/material/icon'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; +import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; +import { SettingsStore } from '@iptvnator/services'; import { ProgressCapsuleComponent, WatchedBadgeComponent, @@ -164,10 +167,9 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { star{{ rating }} } - @let title = i.title ?? i.o_name ?? i.name;
- {{ title || 'No name' }} + {{ channelTitle(i) }}
@@ -229,6 +231,7 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { }) export class GridListComponent { private readonly failedArtworkUrls = signal>(new Set()); + private readonly settingsStore = inject(SettingsStore); readonly items = input([]); readonly isLoading = input(false); @@ -253,6 +256,18 @@ export class GridListComponent { protected readonly hasActiveSearch = computed( () => (this.searchTerm() ?? '').trim().length > 0 ); + /** Prefix stripping applies to channel grids only, never VOD/series. */ + private readonly isLiveGrid = computed(() => + ['live', 'itv', 'radio'].includes(this.type()) + ); + protected readonly channelTitle = (item: GridListItem): string => { + const raw = item.title ?? item.o_name ?? item.name ?? ''; + const stripped = applyChannelNameStrip( + raw, + this.isLiveGrid() && this.settingsStore.stripCountryPrefix?.() + ); + return stripped || 'No name'; + }; readonly skeletonRows = computed(() => { const preferredCount = this.limit() ?? 12; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 86ccb032e..ba0b2cd10 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -136,6 +136,7 @@ describe('UnifiedLiveTabComponent', () => { let component: UnifiedLiveTabComponent; let player: ReturnType>; let epgViewMode: ReturnType>; + let stripCountryPrefix: ReturnType>; let streamResolver: { resolveLiveDetail: jest.Mock; resolveM3uPlaybackDetail: jest.Mock; @@ -171,6 +172,7 @@ describe('UnifiedLiveTabComponent', () => { }; player = signal(VideoPlayer.VideoJs); epgViewMode = signal<'timeline' | 'list'>('timeline'); + stripCountryPrefix = signal(false); portalPlayer = { isEmbeddedPlayer: jest.fn().mockReturnValue(false), openResolvedPlayback: jest.fn(), @@ -196,6 +198,7 @@ describe('UnifiedLiveTabComponent', () => { useValue: { openStreamOnDoubleClick: signal(false), player, + stripCountryPrefix, resolvedEpgViewMode: epgViewMode, }, }, @@ -1061,6 +1064,35 @@ describe('UnifiedLiveTabComponent', () => { ); }); }); + + describe('timeline channel name', () => { + it('strips the country prefix when the setting is enabled', () => { + stripCountryPrefix.set(true); + component.activeDetail.set({ + epgMode: 'portal', + playback: { + streamUrl: 'https://example.com/live.m3u8', + title: 'US | CNN', + }, + epgItems: [], + } as never); + + expect(component.timelineChannelName()).toBe('CNN'); + }); + + it('prefers the M3U channel name and keeps it raw while disabled', () => { + component.activeDetail.set({ + epgMode: 'm3u', + channel: { name: 'US | CNN' }, + playback: { + streamUrl: 'https://example.com/live.m3u8', + title: 'Fallback Title', + }, + } as never); + + expect(component.timelineChannelName()).toBe('US | CNN'); + }); + }); }); function buildLiveItem( diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 7ec920c23..314f3b25c 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -15,6 +15,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { + applyChannelNameStrip, getM3uArchiveDays, isM3uCatchupPlaybackSupported, resolveM3uCatchupUrl, @@ -183,11 +184,12 @@ export class UnifiedLiveTabComponent { ? this.currentM3uPrograms() : this.currentPortalEpgPrograms() ); - readonly timelineChannelName = computed( - () => + readonly timelineChannelName = computed(() => + applyChannelNameStrip( this.currentM3uChannel()?.name ?? - this.activeDetail()?.playback?.title ?? - '' + this.activeDetail()?.playback?.title, + this.settingsStore.stripCountryPrefix?.() + ) ); readonly timelineChannelLogo = computed( () => diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index 36f181d94..ef5eb5fe0 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -136,6 +136,26 @@ describe('SettingsStore dashboard rail settings', () => { expect(store.getSettings().webPlayerSharedControls).toBe(true); }); + it('defaults strip country prefix to false when the stored field is missing', async () => { + storedSettings = {}; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().stripCountryPrefix).toBe(false); + }); + + it('restores a persisted true strip country prefix preference', async () => { + storedSettings = { + stripCountryPrefix: true, + }; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().stripCountryPrefix).toBe(true); + }); + it('normalizes a persisted string "true" shared web controls preference to false', async () => { storedSettings = { webPlayerSharedControls: 'true' as unknown as boolean, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index c896c4466..9ed1187dc 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: Settings = { showDashboard: true, startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, + stripCountryPrefix: false, theme: Theme.SystemTheme, mpvPlayerPath: '', mpvPlayerArguments: '', @@ -196,6 +197,9 @@ export const SettingsStore = signalStore( showExternalPlaybackBar: store.showExternalPlaybackBar?.() ?? DEFAULT_SETTINGS.showExternalPlaybackBar, + stripCountryPrefix: + store.stripCountryPrefix?.() ?? + DEFAULT_SETTINGS.stripCountryPrefix, theme: store.theme(), mpvPlayerPath: store.mpvPlayerPath(), mpvPlayerArguments: store.mpvPlayerArguments(), diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 758d2bd2a..2a86df369 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -92,6 +92,8 @@ export interface Settings { startupBehavior: StartupBehavior; /** Show the desktop footer bar for external playback status */ showExternalPlaybackBar?: boolean; + /** Strip country/group prefixes like "US | " or "UK - " from channel names */ + stripCountryPrefix?: boolean; theme: Theme; mpvPlayerPath: string; /** diff --git a/libs/shared/m3u-utils/src/index.ts b/libs/shared/m3u-utils/src/index.ts index 27a97c636..d50f5a867 100644 --- a/libs/shared/m3u-utils/src/index.ts +++ b/libs/shared/m3u-utils/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/playlist.utils'; export * from './lib/catchup.utils'; +export * from './lib/strip-country-prefix.util'; diff --git a/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts new file mode 100644 index 000000000..abbf90037 --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts @@ -0,0 +1,107 @@ +import { + applyChannelNameStrip, + stripCountryPrefix, +} from './strip-country-prefix.util'; + +describe('stripCountryPrefix', () => { + describe('pipe separators', () => { + it('strips a "XX | " prefix', () => { + expect(stripCountryPrefix('US | CNN')).toBe('CNN'); + }); + + it('strips a bare-pipe prefix', () => { + expect(stripCountryPrefix('FR|TF1')).toBe('TF1'); + }); + + it('strips a wrapped tag like "|DE| ARD"', () => { + expect(stripCountryPrefix('|DE| ARD')).toBe('ARD'); + }); + + it('strips long prefixes before a pipe', () => { + expect(stripCountryPrefix('US East | CNN')).toBe('CNN'); + }); + + it('only strips up to the first pipe', () => { + expect(stripCountryPrefix('US | CNN | HD')).toBe('CNN | HD'); + }); + }); + + describe('dash and colon separators (short-tag prefixes only)', () => { + it('strips a two-letter country tag', () => { + expect(stripCountryPrefix('UK - BBC One')).toBe('BBC One'); + }); + + it('strips a three-letter country tag', () => { + expect(stripCountryPrefix('USA - CNN')).toBe('CNN'); + }); + + it('strips alphanumeric tags like 4K', () => { + expect(stripCountryPrefix('4K - Discovery')).toBe('Discovery'); + }); + + it('strips a colon-separated tag', () => { + expect(stripCountryPrefix('US: CNN')).toBe('CNN'); + }); + + it('only strips the first tag segment', () => { + expect(stripCountryPrefix('ES - A3 - Sports')).toBe('A3 - Sports'); + }); + + it('keeps names whose prefix is not a short uppercase tag', () => { + expect(stripCountryPrefix('Sky - Sports F1')).toBe( + 'Sky - Sports F1' + ); + expect(stripCountryPrefix('Discovery - Science')).toBe( + 'Discovery - Science' + ); + expect(stripCountryPrefix('Mission: Impossible - Fallout')).toBe( + 'Mission: Impossible - Fallout' + ); + }); + + it('keeps hyphenated names without separator spacing', () => { + expect(stripCountryPrefix('T-Mobile TV')).toBe('T-Mobile TV'); + expect(stripCountryPrefix('US-CNN')).toBe('US-CNN'); + }); + + it('keeps single-character prefixes', () => { + expect(stripCountryPrefix('A - Team')).toBe('A - Team'); + }); + }); + + describe('edge cases', () => { + it('returns names without separators unchanged', () => { + expect(stripCountryPrefix('BBC One')).toBe('BBC One'); + }); + + it('trims surrounding whitespace', () => { + expect(stripCountryPrefix(' BBC One ')).toBe('BBC One'); + }); + + it('returns the original name when stripping would leave nothing', () => { + expect(stripCountryPrefix('US | ')).toBe('US |'); + }); + + it('handles empty input', () => { + expect(stripCountryPrefix('')).toBe(''); + expect(stripCountryPrefix(' ')).toBe(''); + }); + }); +}); + +describe('applyChannelNameStrip', () => { + it('strips when enabled', () => { + expect(applyChannelNameStrip('US | CNN', true)).toBe('CNN'); + }); + + it('returns the raw name when disabled', () => { + expect(applyChannelNameStrip('US | CNN', false)).toBe('US | CNN'); + expect(applyChannelNameStrip('US | CNN', undefined)).toBe('US | CNN'); + expect(applyChannelNameStrip('US | CNN', null)).toBe('US | CNN'); + }); + + it('normalizes nullish names to an empty string', () => { + expect(applyChannelNameStrip(null, true)).toBe(''); + expect(applyChannelNameStrip(undefined, false)).toBe(''); + }); +}); diff --git a/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts new file mode 100644 index 000000000..f1213996f --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts @@ -0,0 +1,79 @@ +/** + * Dash/colon separators only count when the prefix looks like a short + * country/group tag — they routinely appear inside real channel names + * ("Sky - Sports F1", "Discovery - Science"). Pipes are conventionally + * used only as tag separators, so they always count. + */ +const DASH_OR_COLON_SEPARATORS = [' - ', '- ', ' -', ': ']; +const TAG_PREFIX_PATTERN = /^[A-Z0-9]{2,3}$/; + +interface SeparatorMatch { + index: number; + length: number; +} + +/** + * Strip country / group prefixes from channel names. + * + * Finds the first tag separator and returns everything to its right. + * Only the prefix is removed — if the separator appears multiple times, + * only the first match counts. If stripping would leave an empty name, + * the original (trimmed) name is returned instead. + * + * Examples: + * "US | CNN" → "CNN" + * "UK - BBC One" → "BBC One" + * "FR|TF1" → "TF1" + * "|DE| ARD" → "ARD" + * "US: CNN" → "CNN" + * "ES - A3 - Sports" → "A3 - Sports" + * "Sky - Sports F1" → "Sky - Sports F1" (prefix is not a short tag) + * "BBC One" → "BBC One" (no separator) + * "US | " → "US |" (stripping would leave nothing) + */ +export function stripCountryPrefix(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return trimmed; + + const match = findTagSeparator(trimmed); + if (!match) return trimmed; + + const prefix = trimmed.slice(0, match.index).trim(); + const stripped = trimmed.slice(match.index + match.length).trim(); + if (!stripped) return trimmed; + + // A leading separator ("|DE| ARD") strips nothing by itself — retry on + // the remainder so the actual tag segment is removed too. + return prefix ? stripped : stripCountryPrefix(stripped); +} + +/** + * Settings-aware wrapper for display bindings: strips the prefix only when + * the `stripCountryPrefix` setting is enabled, and normalizes nullish names + * to an empty string. + */ +export function applyChannelNameStrip( + name: string | null | undefined, + enabled: boolean | null | undefined +): string { + const raw = name ?? ''; + return enabled ? stripCountryPrefix(raw) : raw; +} + +function findTagSeparator(name: string): SeparatorMatch | null { + let best: SeparatorMatch | null = null; + + const pipeIndex = name.indexOf('|'); + if (pipeIndex !== -1) { + best = { index: pipeIndex, length: 1 }; + } + + for (const separator of DASH_OR_COLON_SEPARATORS) { + const index = name.indexOf(separator); + if (index === -1 || (best && best.index <= index)) continue; + if (!TAG_PREFIX_PATTERN.test(name.slice(0, index).trim())) continue; + best = { index, length: separator.length }; + } + + return best; +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index c215f2cb8..8caa8f672 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -28,7 +28,7 @@ }
-
{{ name() }}
+
{{ displayName() }}
@if (showEpg()) { @if (epgProgram(); as program) {
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index e101df100..733e0e2ab 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -1,8 +1,10 @@ import { DatePipe } from '@angular/common'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; +import { SettingsStore } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; import { ChannelListItemComponent } from './channel-list-item.component'; @@ -141,4 +143,48 @@ describe('ChannelListItemComponent', () => { }) ); }); + + it('renders the raw channel name while prefix stripping is disabled', () => { + fixture.componentRef.setInput('name', 'US | CNN'); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.channel-name') + .textContent.trim() + ).toBe('US | CNN'); + }); + + describe('with strip country prefix enabled', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + ChannelListItemComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MatDialog, useValue: dialog }, + { + provide: SettingsStore, + useValue: { stripCountryPrefix: signal(true) }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChannelListItemComponent); + }); + + it('strips the country prefix from the rendered channel name', () => { + fixture.componentRef.setInput('name', 'US | CNN'); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.channel-name') + .textContent.trim() + ).toBe('CNN'); + }); + }); }); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 06400bc0a..97f4f9132 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -3,6 +3,7 @@ import { DatePipe, NgStyle } from '@angular/common'; import { ChangeDetectionStrategy, Component, + computed, effect, inject, input, @@ -16,6 +17,8 @@ import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg'; import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; @Component({ selector: 'app-channel-list-item', @@ -35,10 +38,17 @@ import { EpgProgram } from '@iptvnator/shared/interfaces'; export class ChannelListItemComponent { private readonly dialog = inject(MatDialog); private readonly logoFailed = signal(false); + private readonly settingsStore = inject(SettingsStore); readonly isDraggable = input(false); readonly logo = input(''); readonly name = input(''); + readonly displayName = computed(() => + applyChannelNameStrip( + this.name(), + this.settingsStore.stripCountryPrefix?.() + ) + ); readonly showFavoriteButton = input(false); readonly showAuxActionButton = input(false); readonly showProgramInfoButton = input(true); diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html index 9998539e6..e9d55f521 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html @@ -30,7 +30,7 @@
-
{{ session().title }}
+
{{ displayTitle() }}
{{ playerLabel() }} { .nativeElement.textContent.trim() ).toBe('movie'); }); + + describe('with strip country prefix enabled', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + ExternalPlaybackDockComponent, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: FakeTranslateLoader, + }, + }), + ], + providers: [ + { + provide: SettingsStore, + useValue: { stripCountryPrefix: signal(true) }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ExternalPlaybackDockComponent); + }); + + it('strips the prefix from live session titles', () => { + fixture.componentRef.setInput('session', { + ...session, + title: 'US | CNN', + }); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.external-playback-dock__title') + .textContent.trim() + ).toBe('CNN'); + }); + + it('keeps VOD/episode titles untouched', () => { + fixture.componentRef.setInput('session', { + ...session, + title: 'US | Some Movie', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'vod', + }, + }); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.external-playback-dock__title') + .textContent.trim() + ).toBe('US | Some Movie'); + }); + }); }); diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts index af9e3d4a2..5e5713c83 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts @@ -3,6 +3,7 @@ import { Component, computed, effect, + inject, input, output, signal, @@ -12,6 +13,8 @@ import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; import { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; @Component({ selector: 'app-external-playback-dock', @@ -26,6 +29,8 @@ import { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ExternalPlaybackDockComponent { + private readonly settingsStore = inject(SettingsStore); + readonly session = input.required(); readonly compact = input(false); @@ -33,6 +38,19 @@ export class ExternalPlaybackDockComponent { readonly artworkClicked = output(); private readonly artworkFailed = signal(false); + /** + * Sessions with contentInfo are tracked VOD/episode playback — their + * titles are movie/episode names, so the channel-prefix strip is + * applied only to sessions without it (live channels). + */ + readonly displayTitle = computed(() => + applyChannelNameStrip( + this.session().title, + !this.session().contentInfo && + this.settingsStore.stripCountryPrefix?.() + ) + ); + readonly playerLabel = computed(() => this.session().player.toUpperCase()); readonly artworkUrl = computed( () => this.session().thumbnail?.trim() ?? '' diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index cd930d81e..cd31802ac 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -1,7 +1,8 @@ -import { Component, input, output } from '@angular/core'; +import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; +import { SettingsStore } from '@iptvnator/services'; import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component'; jest.unstable_mockModule('video.js', () => ({ @@ -150,4 +151,55 @@ describe('PortalInlinePlayerComponent', () => { expect(backCount).toBe(1); expect(closedCount).toBe(0); }); + + describe('with strip country prefix enabled', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + PortalInlinePlayerComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: SettingsStore, + useValue: { stripCountryPrefix: signal(true) }, + }, + ], + }) + .overrideComponent(PortalInlinePlayerComponent, { + remove: { + imports: [WebPlayerViewComponent], + }, + add: { + imports: [StubWebPlayerViewComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PortalInlinePlayerComponent); + component = fixture.componentInstance; + }); + + it('strips the prefix from live playback titles', () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'US | CNN', + isLive: true, + }); + fixture.detectChanges(); + + expect(component.title()).toBe('CNN'); + }); + + it('keeps VOD titles untouched', () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'US | Some Movie', + }); + fixture.detectChanges(); + + expect(component.title()).toBe('US | Some Movie'); + }); + }); }); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index bc4291a3e..8a2c72158 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, + inject, input, output, } from '@angular/core'; @@ -15,6 +16,8 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; +import { SettingsStore } from '@iptvnator/services'; +import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component'; import type { SeriesEpisodeMetadata, @@ -43,7 +46,16 @@ export class PortalInlinePlayerComponent { readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesNavigation = input(null); - readonly title = computed(() => this.playback()?.title ?? ''); + private readonly settingsStore = inject(SettingsStore); + // Strip only live-channel titles — VOD/series titles ("Mission: + // Impossible - Fallout") must never lose their leading segment. + readonly title = computed(() => + applyChannelNameStrip( + this.playback()?.title, + this.playback()?.isLive && + this.settingsStore.stripCountryPrefix?.() + ) + ); readonly streamUrl = computed(() => this.playback()?.streamUrl ?? ''); readonly startTime = computed(() => this.playback()?.startTime ?? 0); readonly contentInfo = computed( diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index a61bc5506..632dfa7e8 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -58,7 +58,7 @@
{{ - card.title + cardTitle(card) }} @if (card.nowPlayingTitle) { @@ -134,7 +134,7 @@
{{ - card.title + cardTitle(card) }} @if (card.episodeBadge) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts new file mode 100644 index 000000000..6e6f39a7b --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -0,0 +1,72 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { SettingsStore } from '@iptvnator/services'; +import { + DashboardRailCard, + DashboardRailComponent, +} from './dashboard-rail.component'; + +describe('DashboardRailComponent', () => { + const createComponent = async (stripCountryPrefix: boolean) => { + await TestBed.configureTestingModule({ + imports: [DashboardRailComponent], + providers: [ + { + provide: SettingsStore, + useValue: { + stripCountryPrefix: signal(stripCountryPrefix), + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(DashboardRailComponent); + fixture.componentRef.setInput('label', 'Rail'); + fixture.componentRef.setInput('items', []); + return fixture.componentInstance as unknown as { + cardTitle(card: DashboardRailCard): string; + }; + }; + + const card = (overrides: Partial): DashboardRailCard => + ({ + id: 'card-1', + title: 'US | CNN', + icon: 'live_tv', + link: ['/workspace'], + ...overrides, + }) as DashboardRailCard; + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('strips the prefix from live card titles when the setting is enabled', async () => { + const component = await createComponent(true); + + expect(component.cardTitle(card({ contentType: 'live' }))).toBe('CNN'); + }); + + it('keeps movie and series card titles untouched', async () => { + const component = await createComponent(true); + + expect( + component.cardTitle( + card({ title: 'US | Some Movie', contentType: 'movie' }) + ) + ).toBe('US | Some Movie'); + expect( + component.cardTitle( + card({ title: 'US | Some Show', contentType: 'series' }) + ) + ).toBe('US | Some Show'); + }); + + it('keeps live card titles untouched while the setting is disabled', async () => { + const component = await createComponent(false); + + expect(component.cardTitle(card({ contentType: 'live' }))).toBe( + 'US | CNN' + ); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index a9f5e2d16..9a3cbaa9f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -5,6 +5,7 @@ import { ElementRef, OnDestroy, effect, + inject, input, output, signal, @@ -15,6 +16,8 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { RouterLink } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; +import { SettingsStore } from '@iptvnator/services'; +import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; export interface DashboardRailAction { id: string; @@ -94,6 +97,8 @@ export interface DashboardRailActionSelection { changeDetection: ChangeDetectionStrategy.OnPush, }) export class DashboardRailComponent implements AfterViewInit, OnDestroy { + private readonly settingsStore = inject(SettingsStore); + readonly label = input.required(); readonly items = input.required(); readonly seeAllLink = input(null); @@ -164,6 +169,15 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { event.stopPropagation(); } + /** Prefix stripping applies to live-channel cards only, never VOD/series. */ + protected cardTitle(card: DashboardRailCard): string { + return applyChannelNameStrip( + card.title, + card.contentType === 'live' && + this.settingsStore.stripCountryPrefix?.() + ); + } + selectAction( card: DashboardRailCard, action: DashboardRailAction,