feat(playlist): auto-detect import method that parses pasted provider messages

Adds a deterministic parser (detectProviderImportCandidates in
libs/shared/interfaces) that scans a pasted provider message for URLs, MAC
addresses, and labeled credentials/identity fields (username, password,
serial, device IDs, signatures), classifies the source as Xtream, Stalker,
or an M3U link/body, and returns ranked candidates. Every extracted value is
a verbatim substring or a canonical form from an existing shared normalizer;
detection only proposes — the existing forms and their behavioral probes
(portal discovery, connection test) stay authoritative.

The Add playlist dialog gains a sixth "Auto-detect" method with a textarea
that runs the parser on every edit and renders candidate cards; picking one
switches to the matching import form with the fields prefilled.

Detection runs fully locally; pasted text never leaves the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-11 11:41:47 +00:00
1 parent 5c9411869a
commit 3dee1978f6
35 files changed
+1883 -48

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: playlist
---
The Add playlist dialog has a new "Auto-detect" method: paste whatever your
provider sent you — links, Xtream credentials, a MAC address with device IDs —
and IPTVnator recognizes the source type and fills the matching import form
for you to review. Detection runs entirely inside the app; nothing you paste
is sent anywhere.
+1 -1
View File
@@ -300,7 +300,7 @@ This is an Nx monorepo with the following structure:
- **libs/** - Shared libraries:
- **epg/data-access** - EPG services, runtime bridge, program normalization
- **m3u-state** - NgRx state management for M3U playlists
- **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs)
- **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs, and the "Auto-detect" method: paste a provider message, `detectProviderImportCandidates` in `libs/shared/interfaces` deterministically extracts URLs/credentials/MAC+device identity and prefills the matching form — detection only proposes, the target form's own validation and behavioral probes stay authoritative)
- **playlist/m3u/feature-player** - M3U video player page and `/workspace/playlists/:id` routes
- **playlist/shared/{ui,util}** - Shared playlist UI and utilities
- **portal/xtream/{data-access,feature}** - XtreamStore, services, data sources; routed Xtream components
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "بوابة Stalker",
"METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة",
"METHOD_TEXT_LABEL": "نص m3u الخام",
"METHOD_TEXT_SUB": "للقوائم العابرة أو عندما يتوفر لديك النص فقط"
"METHOD_TEXT_SUB": "للقوائم العابرة أو عندما يتوفر لديك النص فقط",
"METHOD_AUTO_LABEL": "اكتشاف تلقائي",
"METHOD_AUTO_SUB": "الصق رسالة مزوّد الخدمة"
},
"FILE_UPLOAD": {
"DRAG_DROP": "اسحب وأفلت الملفات هنا",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "مستوردة كنص",
"PARSING_ERROR": "خطأ: فشل التحليل، ليست قائمة تشغيل صالحة:"
"PARSING_ERROR": "خطأ: فشل التحليل، ليست قائمة تشغيل صالحة:",
"AUTO_DETECT": {
"LABEL": "رسالة مزوّد الخدمة",
"PLACEHOLDER": "الصق كل ما أرسله مزوّد الخدمة — الروابط واسم المستخدم وكلمة المرور وعنوان MAC…",
"PRIVACY_HINT": "يتم الاكتشاف محليًا داخل التطبيق؛ النص الملصق لا يُرسل إلى أي مكان.",
"NO_MATCHES": "لم يتم التعرف على شيء بعد. الصق الرسالة كاملة مع الرابط أو بيانات الدخول.",
"CONFIDENCE_HIGH": "تطابق مؤكد",
"CONFIDENCE_MEDIUM": "تطابق محتمل",
"CONFIDENCE_LOW": "تخمين",
"USE": "ملء النموذج",
"M3U_TEXT_NOTE": "يبدو أنها قائمة تشغيل M3U — سيتم استيرادها كنص ملصق."
}
},
"SETTINGS": {
"TITLE": "الإعدادات",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "بوابة Stalker",
"METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة",
"METHOD_TEXT_LABEL": "نص m3u خام",
"METHOD_TEXT_SUB": "للقوائم المؤقتة ولا إلى عندك غير النص"
"METHOD_TEXT_SUB": "للقوائم المؤقتة ولا إلى عندك غير النص",
"METHOD_AUTO_LABEL": "اكتشاف تلقائي",
"METHOD_AUTO_SUB": "الصق رسالة مزوّد الخدمة"
},
"FILE_UPLOAD": {
"DRAG_DROP": "جر وأفلت الملفات هنا",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "استورد كنص",
"PARSING_ERROR": "خطأ: التحليل فشل، ماشي قائمة تشغيل صحيحة:"
"PARSING_ERROR": "خطأ: التحليل فشل، ماشي قائمة تشغيل صحيحة:",
"AUTO_DETECT": {
"LABEL": "رسالة مزوّد الخدمة",
"PLACEHOLDER": "الصق كل ما أرسله مزوّد الخدمة — الروابط واسم المستخدم وكلمة المرور وعنوان MAC…",
"PRIVACY_HINT": "يتم الاكتشاف محليًا داخل التطبيق؛ النص الملصق لا يُرسل إلى أي مكان.",
"NO_MATCHES": "لم يتم التعرف على شيء بعد. الصق الرسالة كاملة مع الرابط أو بيانات الدخول.",
"CONFIDENCE_HIGH": "تطابق مؤكد",
"CONFIDENCE_MEDIUM": "تطابق محتمل",
"CONFIDENCE_LOW": "تخمين",
"USE": "ملء النموذج",
"M3U_TEXT_NOTE": "يبدو أنها قائمة تشغيل M3U — سيتم استيرادها كنص ملصق."
}
},
"SETTINGS": {
"TITLE": "الإعدادات",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker партал",
"METHOD_STALKER_SUB": "MAC-адрас і URL партала",
"METHOD_TEXT_LABEL": "Тэкст M3U",
"METHOD_TEXT_SUB": "Для разавых спісаў або калі ў вас ёсць толькі тэкст"
"METHOD_TEXT_SUB": "Для разавых спісаў або калі ў вас ёсць толькі тэкст",
"METHOD_AUTO_LABEL": "Аўтавызначэнне",
"METHOD_AUTO_SUB": "Устаўце паведамленне ад правайдара"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Перацягніце файл у гэтую вобласць",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Імпартавана як тэкст",
"PARSING_ERROR": "Памылка парсінгу, несапраўдны плэйліст:"
"PARSING_ERROR": "Памылка парсінгу, несапраўдны плэйліст:",
"AUTO_DETECT": {
"LABEL": "Паведамленне ад правайдара",
"PLACEHOLDER": "Устаўце ўсё, што даслаў правайдар — спасылкі, лагін і пароль, MAC-адрас…",
"PRIVACY_HINT": "Распазнаванне выконваецца лакальна ў праграме; устаўлены тэкст нікуды не адпраўляецца.",
"NO_MATCHES": "Пакуль нічога не распазнана. Устаўце паведамленне цалкам — са спасылкай або ўліковымі данымі.",
"CONFIDENCE_HIGH": "Дакладнае супадзенне",
"CONFIDENCE_MEDIUM": "Верагоднае супадзенне",
"CONFIDENCE_LOW": "Здагадка",
"USE": "Запоўніць форму",
"M3U_TEXT_NOTE": "Падобна на плэйліст M3U — ён будзе імпартаваны як устаўлены тэкст."
}
},
"SETTINGS": {
"TITLE": "Налады",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker-Portal",
"METHOD_STALKER_SUB": "MAC-Adresse und Portal-URL",
"METHOD_TEXT_LABEL": "M3U-Text",
"METHOD_TEXT_SUB": "Für einmalige Listen oder wenn Sie nur Text haben"
"METHOD_TEXT_SUB": "Für einmalige Listen oder wenn Sie nur Text haben",
"METHOD_AUTO_LABEL": "Automatisch erkennen",
"METHOD_AUTO_SUB": "Nachricht des Anbieters einfügen"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Ziehen Sie Dateien per Drag & Drop",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Als Text importiert",
"PARSING_ERROR": "Fehler: Parsen fehlgeschlagen, keine gültige Playlist:"
"PARSING_ERROR": "Fehler: Parsen fehlgeschlagen, keine gültige Playlist:",
"AUTO_DETECT": {
"LABEL": "Nachricht Ihres Anbieters",
"PLACEHOLDER": "Fügen Sie alles ein, was Ihr Anbieter geschickt hat — Links, Benutzername und Passwort, MAC-Adresse…",
"PRIVACY_HINT": "Die Erkennung läuft lokal in der App; der eingefügte Text wird nirgendwohin gesendet.",
"NO_MATCHES": "Noch nichts erkannt. Fügen Sie die vollständige Nachricht mit Link oder Zugangsdaten ein.",
"CONFIDENCE_HIGH": "Sichere Übereinstimmung",
"CONFIDENCE_MEDIUM": "Wahrscheinliche Übereinstimmung",
"CONFIDENCE_LOW": "Vermutung",
"USE": "Formular ausfüllen",
"M3U_TEXT_NOTE": "Sieht wie eine M3U-Playlist aus — sie wird als eingefügter Text importiert."
}
},
"SETTINGS": {
"TITLE": "Einstellungen",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Πύλη Stalker",
"METHOD_STALKER_SUB": "MAC address και URL πύλης",
"METHOD_TEXT_LABEL": "Ακατέργαστο κείμενο m3u",
"METHOD_TEXT_SUB": "Για λίστες μίας χρήσης ή όταν έχετε μόνο κείμενο"
"METHOD_TEXT_SUB": "Για λίστες μίας χρήσης ή όταν έχετε μόνο κείμενο",
"METHOD_AUTO_LABEL": "Αυτόματη αναγνώριση",
"METHOD_AUTO_SUB": "Επικολλήστε το μήνυμα του παρόχου σας"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Σύρετε και αποθέστε αρχεία εδώ",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Εισήχθη ως κείμενο",
"PARSING_ERROR": "Σφάλμα: Η ανάλυση απέτυχε, δεν είναι έγκυρη λίστα αναπαραγωγής:"
"PARSING_ERROR": "Σφάλμα: Η ανάλυση απέτυχε, δεν είναι έγκυρη λίστα αναπαραγωγής:",
"AUTO_DETECT": {
"LABEL": "Μήνυμα του παρόχου σας",
"PLACEHOLDER": "Επικολλήστε ό,τι σας έστειλε ο πάροχος — συνδέσμους, όνομα χρήστη και κωδικό, διεύθυνση MAC…",
"PRIVACY_HINT": "Η αναγνώριση γίνεται τοπικά στην εφαρμογή· το κείμενο δεν αποστέλλεται πουθενά.",
"NO_MATCHES": "Δεν αναγνωρίστηκε τίποτα ακόμη. Επικολλήστε ολόκληρο το μήνυμα με τον σύνδεσμο ή τα διαπιστευτήρια.",
"CONFIDENCE_HIGH": "Σίγουρη αντιστοιχία",
"CONFIDENCE_MEDIUM": "Πιθανή αντιστοιχία",
"CONFIDENCE_LOW": "Εικασία",
"USE": "Συμπλήρωση φόρμας",
"M3U_TEXT_NOTE": "Μοιάζει με λίστα M3U — θα εισαχθεί ως επικολλημένο κείμενο."
}
},
"SETTINGS": {
"TITLE": "Ρυθμίσεις",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker portal",
"METHOD_STALKER_SUB": "MAC address and portal URL",
"METHOD_TEXT_LABEL": "Raw m3u text",
"METHOD_TEXT_SUB": "For one-off lists or when you only have text"
"METHOD_TEXT_SUB": "For one-off lists or when you only have text",
"METHOD_AUTO_LABEL": "Auto-detect",
"METHOD_AUTO_SUB": "Paste the message from your provider"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Drop an .m3u or .m3u8 file here",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Imported as text",
"PARSING_ERROR": "Error: Parsing failed, not a valid playlist:"
"PARSING_ERROR": "Error: Parsing failed, not a valid playlist:",
"AUTO_DETECT": {
"LABEL": "Message from your provider",
"PLACEHOLDER": "Paste everything your provider sent — links, username and password, MAC address…",
"PRIVACY_HINT": "Detection runs locally in the app; nothing you paste is sent anywhere.",
"NO_MATCHES": "Nothing recognized yet. Paste the full message including the link or credentials.",
"CONFIDENCE_HIGH": "Confident match",
"CONFIDENCE_MEDIUM": "Likely match",
"CONFIDENCE_LOW": "Guess",
"USE": "Fill the form",
"M3U_TEXT_NOTE": "Looks like a raw M3U playlist — it will be imported as pasted text."
}
},
"SETTINGS": {
"TITLE": "Settings",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Portal Stalker",
"METHOD_STALKER_SUB": "Dirección MAC y URL del portal",
"METHOD_TEXT_LABEL": "Texto m3u sin formato",
"METHOD_TEXT_SUB": "Para listas puntuales o cuando solo tienes texto"
"METHOD_TEXT_SUB": "Para listas puntuales o cuando solo tienes texto",
"METHOD_AUTO_LABEL": "Detección automática",
"METHOD_AUTO_SUB": "Pega el mensaje de tu proveedor"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Arrastre & suelte archivos aquí",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Importado como texto",
"PARSING_ERROR": "Error: Error al analizar, no es una lista de reproducción válida:"
"PARSING_ERROR": "Error: Error al analizar, no es una lista de reproducción válida:",
"AUTO_DETECT": {
"LABEL": "Mensaje de tu proveedor",
"PLACEHOLDER": "Pega todo lo que te envió tu proveedor: enlaces, usuario y contraseña, dirección MAC…",
"PRIVACY_HINT": "La detección se realiza localmente en la aplicación; el texto pegado no se envía a ningún sitio.",
"NO_MATCHES": "Aún no se ha reconocido nada. Pega el mensaje completo con el enlace o las credenciales.",
"CONFIDENCE_HIGH": "Coincidencia segura",
"CONFIDENCE_MEDIUM": "Coincidencia probable",
"CONFIDENCE_LOW": "Suposición",
"USE": "Rellenar el formulario",
"M3U_TEXT_NOTE": "Parece una lista M3U: se importará como texto pegado."
}
},
"SETTINGS": {
"TITLE": "Ajustes",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Portail Stalker",
"METHOD_STALKER_SUB": "Adresse MAC et URL du portail",
"METHOD_TEXT_LABEL": "Texte m3u brut",
"METHOD_TEXT_SUB": "Pour les listes ponctuelles ou quand vous n'avez que du texte"
"METHOD_TEXT_SUB": "Pour les listes ponctuelles ou quand vous n'avez que du texte",
"METHOD_AUTO_LABEL": "Détection automatique",
"METHOD_AUTO_SUB": "Collez le message de votre fournisseur"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Glissez un fichier ici",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Importé sous forme de texte",
"PARSING_ERROR": "Erreur : échec de l'analyse, liste de lecture non valide :"
"PARSING_ERROR": "Erreur : échec de l'analyse, liste de lecture non valide :",
"AUTO_DETECT": {
"LABEL": "Message de votre fournisseur",
"PLACEHOLDER": "Collez tout ce que votre fournisseur a envoyé : liens, identifiant et mot de passe, adresse MAC…",
"PRIVACY_HINT": "La détection s'effectue localement dans l'application ; le texte collé n'est envoyé nulle part.",
"NO_MATCHES": "Rien de reconnu pour l'instant. Collez le message complet avec le lien ou les identifiants.",
"CONFIDENCE_HIGH": "Correspondance sûre",
"CONFIDENCE_MEDIUM": "Correspondance probable",
"CONFIDENCE_LOW": "Supposition",
"USE": "Remplir le formulaire",
"M3U_TEXT_NOTE": "Ressemble à une liste M3U — elle sera importée comme texte collé."
}
},
"SETTINGS": {
"TITLE": "Paramètres",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker-portál",
"METHOD_STALKER_SUB": "MAC-cím és a portál URL-címe",
"METHOD_TEXT_LABEL": "Nyers M3U-szöveg",
"METHOD_TEXT_SUB": "Egyszeri listákhoz, vagy ha csak szöveg áll rendelkezésére"
"METHOD_TEXT_SUB": "Egyszeri listákhoz, vagy ha csak szöveg áll rendelkezésére",
"METHOD_AUTO_LABEL": "Automatikus felismerés",
"METHOD_AUTO_SUB": "Illessze be a szolgáltató üzenetét"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Húzzon ide egy .m3u- vagy .m3u8-fájlt",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Szövegként importálva",
"PARSING_ERROR": "Hiba: a feldolgozás sikertelen; ez nem érvényes lejátszási lista:"
"PARSING_ERROR": "Hiba: a feldolgozás sikertelen; ez nem érvényes lejátszási lista:",
"AUTO_DETECT": {
"LABEL": "A szolgáltató üzenete",
"PLACEHOLDER": "Illesszen be mindent, amit a szolgáltató küldött — linkek, felhasználónév és jelszó, MAC-cím…",
"PRIVACY_HINT": "A felismerés helyben, az alkalmazásban történik; a beillesztett szöveg nem kerül elküldésre.",
"NO_MATCHES": "Még semmi sem felismerhető. Illessze be a teljes üzenetet a linkkel vagy a belépési adatokkal.",
"CONFIDENCE_HIGH": "Biztos találat",
"CONFIDENCE_MEDIUM": "Valószínű találat",
"CONFIDENCE_LOW": "Feltételezés",
"USE": "Űrlap kitöltése",
"M3U_TEXT_NOTE": "M3U-lejátszási listának tűnik — beillesztett szövegként lesz importálva."
}
},
"SETTINGS": {
"TITLE": "Beállítások",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Portale Stalker",
"METHOD_STALKER_SUB": "Indirizzo MAC e URL del portale",
"METHOD_TEXT_LABEL": "Testo m3u grezzo",
"METHOD_TEXT_SUB": "Per liste occasionali o quando hai solo il testo"
"METHOD_TEXT_SUB": "Per liste occasionali o quando hai solo il testo",
"METHOD_AUTO_LABEL": "Rilevamento automatico",
"METHOD_AUTO_SUB": "Incolla il messaggio del tuo provider"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Trascina e rilascia qui i file",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Importato come testo",
"PARSING_ERROR": "Errore: analisi fallita, non è una playlist valida:"
"PARSING_ERROR": "Errore: analisi fallita, non è una playlist valida:",
"AUTO_DETECT": {
"LABEL": "Messaggio del tuo provider",
"PLACEHOLDER": "Incolla tutto ciò che il provider ti ha inviato: link, nome utente e password, indirizzo MAC…",
"PRIVACY_HINT": "Il rilevamento avviene localmente nell'app; il testo incollato non viene inviato da nessuna parte.",
"NO_MATCHES": "Ancora nulla di riconosciuto. Incolla il messaggio completo con il link o le credenziali.",
"CONFIDENCE_HIGH": "Corrispondenza sicura",
"CONFIDENCE_MEDIUM": "Corrispondenza probabile",
"CONFIDENCE_LOW": "Ipotesi",
"USE": "Compila il modulo",
"M3U_TEXT_NOTE": "Sembra una playlist M3U: verrà importata come testo incollato."
}
},
"SETTINGS": {
"TITLE": "Impostazioni IPTVnator",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalkerポータル",
"METHOD_STALKER_SUB": "MACアドレスとポータルURL",
"METHOD_TEXT_LABEL": "M3Uテキスト",
"METHOD_TEXT_SUB": "一度限りのリストやテキストしかない場合に"
"METHOD_TEXT_SUB": "一度限りのリストやテキストしかない場合に",
"METHOD_AUTO_LABEL": "自動検出",
"METHOD_AUTO_SUB": "プロバイダーからのメッセージを貼り付け"
},
"FILE_UPLOAD": {
"DRAG_DROP": "ここにファイルをドラッグ&ドロップ",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "テキストとしてインポート",
"PARSING_ERROR": "エラー:解析に失敗しました。有効なプレイリストではありません:"
"PARSING_ERROR": "エラー:解析に失敗しました。有効なプレイリストではありません:",
"AUTO_DETECT": {
"LABEL": "プロバイダーからのメッセージ",
"PLACEHOLDER": "プロバイダーから送られた内容をすべて貼り付けてください — リンク、ユーザー名とパスワード、MACアドレスなど…",
"PRIVACY_HINT": "検出はアプリ内でローカルに実行され、貼り付けたテキストはどこにも送信されません。",
"NO_MATCHES": "まだ何も認識されていません。リンクや認証情報を含むメッセージ全体を貼り付けてください。",
"CONFIDENCE_HIGH": "確実な一致",
"CONFIDENCE_MEDIUM": "可能性の高い一致",
"CONFIDENCE_LOW": "推測",
"USE": "フォームに入力",
"M3U_TEXT_NOTE": "M3Uプレイリストのようです — 貼り付けたテキストとしてインポートされます。"
}
},
"SETTINGS": {
"TITLE": "設定",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker 포털",
"METHOD_STALKER_SUB": "MAC 주소와 포털 URL",
"METHOD_TEXT_LABEL": "원본 m3u 텍스트",
"METHOD_TEXT_SUB": "일회성 목록이나 텍스트만 있을 때 사용하세요"
"METHOD_TEXT_SUB": "일회성 목록이나 텍스트만 있을 때 사용하세요",
"METHOD_AUTO_LABEL": "자동 감지",
"METHOD_AUTO_SUB": "제공업체의 메시지를 붙여넣으세요"
},
"FILE_UPLOAD": {
"DRAG_DROP": "파일을 드래그 앤 드롭",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "텍스트로 가져옴",
"PARSING_ERROR": "오류: 파싱에 실패했습니다. 유효한 재생목록이 아닙니다:"
"PARSING_ERROR": "오류: 파싱에 실패했습니다. 유효한 재생목록이 아닙니다:",
"AUTO_DETECT": {
"LABEL": "제공업체의 메시지",
"PLACEHOLDER": "제공업체가 보낸 모든 내용을 붙여넣으세요 — 링크, 사용자 이름과 비밀번호, MAC 주소…",
"PRIVACY_HINT": "감지는 앱에서 로컬로 실행되며 붙여넣은 텍스트는 어디에도 전송되지 않습니다.",
"NO_MATCHES": "아직 인식된 내용이 없습니다. 링크나 자격 증명이 포함된 전체 메시지를 붙여넣으세요.",
"CONFIDENCE_HIGH": "확실한 일치",
"CONFIDENCE_MEDIUM": "유력한 일치",
"CONFIDENCE_LOW": "추측",
"USE": "양식 채우기",
"M3U_TEXT_NOTE": "M3U 재생목록으로 보입니다 — 붙여넣은 텍스트로 가져옵니다."
}
},
"SETTINGS": {
"TITLE": "설정",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker-portaal",
"METHOD_STALKER_SUB": "MAC-adres en portal-URL",
"METHOD_TEXT_LABEL": "Ruwe m3u-tekst",
"METHOD_TEXT_SUB": "Voor eenmalige lijsten of als je alleen tekst hebt"
"METHOD_TEXT_SUB": "Voor eenmalige lijsten of als je alleen tekst hebt",
"METHOD_AUTO_LABEL": "Automatisch detecteren",
"METHOD_AUTO_SUB": "Plak het bericht van je provider"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Sleep bestanden hierheen",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Geïmporteerd als tekst",
"PARSING_ERROR": "Fout: parsing mislukt, geen geldige afspeellijst:"
"PARSING_ERROR": "Fout: parsing mislukt, geen geldige afspeellijst:",
"AUTO_DETECT": {
"LABEL": "Bericht van je provider",
"PLACEHOLDER": "Plak alles wat je provider heeft gestuurd — links, gebruikersnaam en wachtwoord, MAC-adres…",
"PRIVACY_HINT": "Detectie gebeurt lokaal in de app; geplakte tekst wordt nergens naartoe gestuurd.",
"NO_MATCHES": "Nog niets herkend. Plak het volledige bericht met de link of inloggegevens.",
"CONFIDENCE_HIGH": "Zekere match",
"CONFIDENCE_MEDIUM": "Waarschijnlijke match",
"CONFIDENCE_LOW": "Gok",
"USE": "Formulier invullen",
"M3U_TEXT_NOTE": "Lijkt op een M3U-afspeellijst — deze wordt als geplakte tekst geïmporteerd."
}
},
"SETTINGS": {
"TITLE": "Instellingen",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Portal Stalker",
"METHOD_STALKER_SUB": "Adres MAC i URL portalu",
"METHOD_TEXT_LABEL": "Surowy tekst m3u",
"METHOD_TEXT_SUB": "Do jednorazowych list lub gdy dostępny jest tylko tekst"
"METHOD_TEXT_SUB": "Do jednorazowych list lub gdy dostępny jest tylko tekst",
"METHOD_AUTO_LABEL": "Automatyczne wykrywanie",
"METHOD_AUTO_SUB": "Wklej wiadomość od dostawcy"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Przeciągnij i upuść pliki tutaj",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Zaimportowano jako tekst",
"PARSING_ERROR": "Błąd: Analiza nie powiodła się, nieprawidłowa lista odtwarzania:"
"PARSING_ERROR": "Błąd: Analiza nie powiodła się, nieprawidłowa lista odtwarzania:",
"AUTO_DETECT": {
"LABEL": "Wiadomość od dostawcy",
"PLACEHOLDER": "Wklej wszystko, co przysłał dostawca — linki, login i hasło, adres MAC…",
"PRIVACY_HINT": "Wykrywanie odbywa się lokalnie w aplikacji; wklejony tekst nigdzie nie jest wysyłany.",
"NO_MATCHES": "Na razie nic nie rozpoznano. Wklej całą wiadomość z linkiem lub danymi logowania.",
"CONFIDENCE_HIGH": "Pewne dopasowanie",
"CONFIDENCE_MEDIUM": "Prawdopodobne dopasowanie",
"CONFIDENCE_LOW": "Przypuszczenie",
"USE": "Wypełnij formularz",
"M3U_TEXT_NOTE": "Wygląda na playlistę M3U — zostanie zaimportowana jako wklejony tekst."
}
},
"SETTINGS": {
"TITLE": "Ustawienia",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Portal Stalker",
"METHOD_STALKER_SUB": "Endereço MAC e URL do portal",
"METHOD_TEXT_LABEL": "Texto m3u bruto",
"METHOD_TEXT_SUB": "Para listas pontuais ou quando você só tem o texto"
"METHOD_TEXT_SUB": "Para listas pontuais ou quando você só tem o texto",
"METHOD_AUTO_LABEL": "Deteção automática",
"METHOD_AUTO_SUB": "Cole a mensagem do seu provedor"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Arraste e solte os arquivos aqui",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Importado como texto",
"PARSING_ERROR": "Erro: Falha na análise, não é uma playlist válida:"
"PARSING_ERROR": "Erro: Falha na análise, não é uma playlist válida:",
"AUTO_DETECT": {
"LABEL": "Mensagem do seu provedor",
"PLACEHOLDER": "Cole tudo o que o provedor enviou — links, utilizador e palavra-passe, endereço MAC…",
"PRIVACY_HINT": "A deteção é feita localmente na aplicação; o texto colado não é enviado para lado nenhum.",
"NO_MATCHES": "Ainda nada reconhecido. Cole a mensagem completa com o link ou as credenciais.",
"CONFIDENCE_HIGH": "Correspondência segura",
"CONFIDENCE_MEDIUM": "Correspondência provável",
"CONFIDENCE_LOW": "Palpite",
"USE": "Preencher o formulário",
"M3U_TEXT_NOTE": "Parece uma playlist M3U — será importada como texto colado."
}
},
"SETTINGS": {
"TITLE": "Configurações",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker портал",
"METHOD_STALKER_SUB": "MAC-адрес и URL портала",
"METHOD_TEXT_LABEL": "M3U текстом",
"METHOD_TEXT_SUB": "Для разовых плейлистов или когда есть только текст"
"METHOD_TEXT_SUB": "Для разовых плейлистов или когда есть только текст",
"METHOD_AUTO_LABEL": "Автоопределение",
"METHOD_AUTO_SUB": "Вставьте сообщение от провайдера"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Перетащите файл в эту область",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Импортировано как текст",
"PARSING_ERROR": "Ошибка парсинга, недействительный плейлист:"
"PARSING_ERROR": "Ошибка парсинга, недействительный плейлист:",
"AUTO_DETECT": {
"LABEL": "Сообщение от провайдера",
"PLACEHOLDER": "Вставьте всё, что прислал провайдер — ссылки, логин и пароль, MAC-адрес…",
"PRIVACY_HINT": "Распознавание выполняется локально в приложении; вставленный текст никуда не отправляется.",
"NO_MATCHES": "Пока ничего не распознано. Вставьте сообщение целиком — со ссылкой или учётными данными.",
"CONFIDENCE_HIGH": "Точное совпадение",
"CONFIDENCE_MEDIUM": "Вероятное совпадение",
"CONFIDENCE_LOW": "Предположение",
"USE": "Заполнить форму",
"M3U_TEXT_NOTE": "Похоже на плейлист M3U — он будет импортирован как вставленный текст."
}
},
"SETTINGS": {
"TITLE": "Настройки",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker portalı",
"METHOD_STALKER_SUB": "MAC adresi ve portal URL'si",
"METHOD_TEXT_LABEL": "Ham m3u metni",
"METHOD_TEXT_SUB": "Tek seferlik listeler veya yalnızca metniniz olduğunda"
"METHOD_TEXT_SUB": "Tek seferlik listeler veya yalnızca metniniz olduğunda",
"METHOD_AUTO_LABEL": "Otomatik algılama",
"METHOD_AUTO_SUB": "Sağlayıcınızın mesajını yapıştırın"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Dosyaları buraya sürükleyip bırakın",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Metin olarak içe aktarıldı",
"PARSING_ERROR": "Hata: Ayrıştırma başarısız, geçerli bir oynatma listesi değil:"
"PARSING_ERROR": "Hata: Ayrıştırma başarısız, geçerli bir oynatma listesi değil:",
"AUTO_DETECT": {
"LABEL": "Sağlayıcınızın mesajı",
"PLACEHOLDER": "Sağlayıcınızın gönderdiği her şeyi yapıştırın — bağlantılar, kullanıcı adı ve şifre, MAC adresi…",
"PRIVACY_HINT": "Algılama uygulama içinde yerel olarak yapılır; yapıştırılan metin hiçbir yere gönderilmez.",
"NO_MATCHES": "Henüz bir şey tanınmadı. Bağlantı veya kimlik bilgileriyle birlikte mesajın tamamını yapıştırın.",
"CONFIDENCE_HIGH": "Kesin eşleşme",
"CONFIDENCE_MEDIUM": "Olası eşleşme",
"CONFIDENCE_LOW": "Tahmin",
"USE": "Formu doldur",
"M3U_TEXT_NOTE": "M3U çalma listesine benziyor — yapıştırılan metin olarak içe aktarılacak."
}
},
"SETTINGS": {
"TITLE": "Ayarlar",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker 门户",
"METHOD_STALKER_SUB": "MAC 地址和门户 URL",
"METHOD_TEXT_LABEL": "原始 m3u 文本",
"METHOD_TEXT_SUB": "用于一次性列表,或仅有文本时"
"METHOD_TEXT_SUB": "用于一次性列表,或仅有文本时",
"METHOD_AUTO_LABEL": "自动识别",
"METHOD_AUTO_SUB": "粘贴服务商发来的消息"
},
"FILE_UPLOAD": {
"DRAG_DROP": "将文件拖放到此处",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "以文本形式导入",
"PARSING_ERROR": "错误:解析失败,不是有效的播放列表:"
"PARSING_ERROR": "错误:解析失败,不是有效的播放列表:",
"AUTO_DETECT": {
"LABEL": "服务商发来的消息",
"PLACEHOLDER": "粘贴服务商发送的全部内容——链接、用户名和密码、MAC 地址等…",
"PRIVACY_HINT": "识别在应用内本地完成;粘贴的文本不会被发送到任何地方。",
"NO_MATCHES": "尚未识别出任何内容。请粘贴包含链接或凭据的完整消息。",
"CONFIDENCE_HIGH": "确定匹配",
"CONFIDENCE_MEDIUM": "可能匹配",
"CONFIDENCE_LOW": "推测",
"USE": "填写表单",
"M3U_TEXT_NOTE": "看起来是 M3U 播放列表——将作为粘贴文本导入。"
}
},
"SETTINGS": {
"TITLE": "设置",
+15 -2
View File
@@ -144,7 +144,9 @@
"METHOD_STALKER_LABEL": "Stalker 入口網站",
"METHOD_STALKER_SUB": "MAC 位址與入口網址",
"METHOD_TEXT_LABEL": "M3U 原始文字",
"METHOD_TEXT_SUB": "適合一次性清單,或您只有文字內容時使用"
"METHOD_TEXT_SUB": "適合一次性清單,或您只有文字內容時使用",
"METHOD_AUTO_LABEL": "自動辨識",
"METHOD_AUTO_SUB": "貼上服務商傳來的訊息"
},
"FILE_UPLOAD": {
"DRAG_DROP": "在此處拖放檔案",
@@ -228,7 +230,18 @@
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "以文字方式匯入",
"PARSING_ERROR": "錯誤:解析失敗,不是有效的播放清單:"
"PARSING_ERROR": "錯誤:解析失敗,不是有效的播放清單:",
"AUTO_DETECT": {
"LABEL": "服務商傳來的訊息",
"PLACEHOLDER": "貼上服務商傳送的全部內容——連結、使用者名稱與密碼、MAC 位址等…",
"PRIVACY_HINT": "辨識在應用程式內本機完成;貼上的文字不會傳送到任何地方。",
"NO_MATCHES": "尚未辨識出任何內容。請貼上包含連結或憑證的完整訊息。",
"CONFIDENCE_HIGH": "確定匹配",
"CONFIDENCE_MEDIUM": "可能匹配",
"CONFIDENCE_LOW": "推測",
"USE": "填寫表單",
"M3U_TEXT_NOTE": "看起來是 M3U 播放清單——將作為貼上的文字匯入。"
}
},
"SETTINGS": {
"TITLE": "設定",
@@ -57,6 +57,11 @@
@case ('xtream') {
<app-xtream-code-import (addClicked)="closeDialog()" />
}
@case ('auto') {
<app-auto-import
(candidateSelected)="onCandidateSelected($event)"
/>
}
}
</div>
</mat-dialog-content>
@@ -56,7 +56,10 @@
// exist). Each card is self-describing — icon + label + one-line sub.
.apd-method-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
// 6 methods (URL, file, Xtream, Stalker, text, auto-detect) — a 3×2
// grid keeps the cards wide enough for their subtitles; 5 columns left
// a lone orphan card on the second row.
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 4px 0 16px;
}
@@ -163,7 +166,7 @@
line-height: 1.4;
}
// On narrow widths the 5-column grid wraps to 2 columns to stay legible.
// On narrow widths the 3-column grid wraps to 2 columns to stay legible.
// 480px keeps the dialog usable down to small modals; below that it stacks
// fully.
@media (max-width: 720px) {
@@ -5,7 +5,10 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DataService } from '@iptvnator/services';
import { PLAYLIST_PARSE_BY_URL } from '@iptvnator/shared/interfaces';
import {
PLAYLIST_PARSE_BY_URL,
ProviderImportCandidate,
} from '@iptvnator/shared/interfaces';
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import { AddPlaylistDialogComponent } from './add-playlist-dialog.component';
@@ -152,6 +155,11 @@ describe('AddPlaylistDialogComponent', () => {
childAccessor: 'stalkerImport',
clearMethod: 'clearForm',
},
{
type: 'auto',
childAccessor: 'autoImport',
clearMethod: 'clearForm',
},
] as const)(
'clears the current $type import surface',
({ type, childAccessor, clearMethod }) => {
@@ -224,9 +232,158 @@ describe('AddPlaylistDialogComponent', () => {
}
);
describe('auto-detect candidate handoff', () => {
const applyPrefill = () =>
(
component as unknown as { applyPendingPrefill(): void }
).applyPendingPrefill();
it.each([
['xtream', 'xtream'],
['stalker', 'stalker'],
['m3u-url', 'url'],
['m3u-text', 'text'],
] as const)(
'switches to the %s import form for a %s candidate',
(kind, expectedMethod) => {
component.onCandidateSelected({
kind,
confidence: 'high',
} as ProviderImportCandidate);
expect(component.method()).toBe(expectedMethod);
}
);
it('prefills the xtream form once the child exists and clears the pending candidate', () => {
const patchValue = jest.fn();
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => ({ form: { patchValue } })
);
component.onCandidateSelected({
kind: 'xtream',
confidence: 'high',
serverUrl: 'http://tv.example.com:8080',
username: 'alice',
password: 's3cret',
suggestedTitle: 'tv.example.com',
});
applyPrefill();
expect(patchValue).toHaveBeenCalledWith({
title: 'tv.example.com',
serverUrl: 'http://tv.example.com:8080',
username: 'alice',
password: 's3cret',
});
// A second run must be a no-op — the candidate was consumed.
applyPrefill();
expect(patchValue).toHaveBeenCalledTimes(1);
});
it('keeps the candidate pending while the target form does not exist yet', () => {
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => undefined
);
const patchValue = jest.fn();
component.onCandidateSelected({
kind: 'xtream',
confidence: 'high',
username: 'alice',
});
applyPrefill();
// Child appears on a later change-detection pass.
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => ({ form: { patchValue } })
);
applyPrefill();
expect(patchValue).toHaveBeenCalledWith(
expect.objectContaining({ username: 'alice' })
);
});
it('prefills the stalker form including identity fields', () => {
const patchValue = jest.fn();
(component as { stalkerImport: jest.Mock }).stalkerImport =
jest.fn(() => ({ form: { patchValue } }));
component.onCandidateSelected({
kind: 'stalker',
confidence: 'high',
portalUrl: 'http://stb.example.com/c/',
macAddress: '00:1A:79:12:34:56',
serialNumber: 'SN123',
deviceId1: 'a'.repeat(64),
deviceId2: 'b'.repeat(64),
signature1: 'c'.repeat(64),
signature2: 'd'.repeat(64),
username: 'stbuser',
password: 'stbpass',
suggestedTitle: 'stb.example.com',
});
applyPrefill();
expect(patchValue).toHaveBeenCalledWith({
title: 'stb.example.com',
portalUrl: 'http://stb.example.com/c/',
macAddress: '00:1A:79:12:34:56',
serialNumber: 'SN123',
deviceId1: 'a'.repeat(64),
deviceId2: 'b'.repeat(64),
signature1: 'c'.repeat(64),
signature2: 'd'.repeat(64),
username: 'stbuser',
password: 'stbpass',
});
});
it('prefills the URL form for an m3u-url candidate', () => {
const patchValue = jest.fn();
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
form: { patchValue },
}));
component.onCandidateSelected({
kind: 'm3u-url',
confidence: 'high',
url: 'https://lists.example.com/main.m3u',
suggestedTitle: 'lists.example.com',
});
applyPrefill();
expect(patchValue).toHaveBeenCalledWith({
playlistUrl: 'https://lists.example.com/main.m3u',
playlistName: 'lists.example.com',
});
});
it('prefills the raw-text form for an m3u-text candidate', () => {
const patchValue = jest.fn();
(component as { textImport: jest.Mock }).textImport = jest.fn(
() => ({ textForm: { patchValue } })
);
component.onCandidateSelected({
kind: 'm3u-text',
confidence: 'high',
text: '#EXTM3U\nhttp://streams.example.com/1.ts',
});
applyPrefill();
expect(patchValue).toHaveBeenCalledWith({
text: '#EXTM3U\nhttp://streams.example.com/1.ts',
});
});
});
function selectType(type: PlaylistType): void {
// The dialog now uses a single `method` signal across all 5 source
// types — no more category × subtype matrix.
// The dialog now uses a single `method` signal across all 6 source
// methods — no more category × subtype matrix.
component.method.set(type);
}
});
@@ -1,6 +1,7 @@
import {
Component,
computed,
effect,
inject,
signal,
ViewEncapsulation,
@@ -19,7 +20,11 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DataService } from '@iptvnator/services';
import { PLAYLIST_PARSE_BY_URL } from '@iptvnator/shared/interfaces';
import {
PLAYLIST_PARSE_BY_URL,
ProviderImportCandidate,
} from '@iptvnator/shared/interfaces';
import { AutoImportComponent } from '../auto-import/auto-import.component';
import { FileUploadComponent } from '../file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../text-import/text-import.component';
@@ -38,8 +43,20 @@ export interface PlaylistMethodOption {
subKey: string;
}
/** The import form a picked auto-detect candidate prefills. */
const METHOD_BY_CANDIDATE_KIND: Record<
ProviderImportCandidate['kind'],
PlaylistType
> = {
xtream: 'xtream',
stalker: 'stalker',
'm3u-url': 'url',
'm3u-text': 'text',
};
@Component({
imports: [
AutoImportComponent,
FileUploadComponent,
MatButtonModule,
MatDialogModule,
@@ -70,9 +87,20 @@ export class AddPlaylistDialogComponent {
readonly textImport = viewChild(TextImportComponent);
readonly xtreamImport = viewChild(XtreamCodeImportComponent);
readonly stalkerImport = viewChild(StalkerPortalImportComponent);
readonly autoImport = viewChild(AutoImportComponent);
readonly method = signal<PlaylistType>('url');
/**
* Candidate picked in the auto-detect surface, waiting for its target
* form to exist. Selecting a candidate switches `method`, which only
* instantiates the target child on the NEXT change-detection pass — so
* the prefill is applied by an effect that re-runs once the child's
* viewChild signal resolves, then clears this.
*/
private readonly pendingPrefill =
signal<ProviderImportCandidate | null>(null);
// Order matches the v0.22 mockup left-to-right: URL first (Most common),
// then File, Xtream credentials, Stalker portal, raw text paste. Each
// entry stands on its own — no nested subtypes. Labels are short and
@@ -110,6 +138,12 @@ export class AddPlaylistDialogComponent {
labelKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL',
subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB',
},
{
value: 'auto',
icon: 'auto_awesome',
labelKey: 'HOME.ADD_PLAYLIST.METHOD_AUTO_LABEL',
subKey: 'HOME.ADD_PLAYLIST.METHOD_AUTO_SUB',
},
];
/**
@@ -122,6 +156,79 @@ export class AddPlaylistDialogComponent {
if (this.data?.type) {
this.method.set(this.data.type);
}
effect(() => this.applyPendingPrefill());
}
/**
* Hands a detected source to the matching import form. Only prefills —
* the user reviews and submits through the regular form, so validation
* and the behavioral probes (portal discovery, connection test) stay in
* charge of what actually gets persisted.
*/
onCandidateSelected(candidate: ProviderImportCandidate): void {
this.pendingPrefill.set(candidate);
this.method.set(METHOD_BY_CANDIDATE_KIND[candidate.kind]);
}
private applyPendingPrefill(): void {
const candidate = this.pendingPrefill();
if (!candidate) {
return;
}
switch (candidate.kind) {
case 'm3u-url': {
const child = this.urlUpload();
if (!child) {
return;
}
child.form.patchValue({
playlistUrl: candidate.url ?? '',
playlistName: candidate.suggestedTitle ?? '',
});
break;
}
case 'm3u-text': {
const child = this.textImport();
if (!child) {
return;
}
child.textForm.patchValue({ text: candidate.text ?? '' });
break;
}
case 'xtream': {
const child = this.xtreamImport();
if (!child) {
return;
}
child.form.patchValue({
title: candidate.suggestedTitle ?? '',
serverUrl: candidate.serverUrl ?? '',
username: candidate.username ?? '',
password: candidate.password ?? '',
});
break;
}
case 'stalker': {
const child = this.stalkerImport();
if (!child) {
return;
}
child.form.patchValue({
title: candidate.suggestedTitle ?? '',
portalUrl: candidate.portalUrl ?? '',
macAddress: candidate.macAddress ?? '',
serialNumber: candidate.serialNumber ?? '',
deviceId1: candidate.deviceId1 ?? '',
deviceId2: candidate.deviceId2 ?? '',
signature1: candidate.signature1 ?? '',
signature2: candidate.signature2 ?? '',
username: candidate.username ?? '',
password: candidate.password ?? '',
});
break;
}
}
this.pendingPrefill.set(null);
}
/**
@@ -196,6 +303,9 @@ export class AddPlaylistDialogComponent {
case 'stalker':
this.stalkerImport()?.clearForm();
break;
case 'auto':
this.autoImport()?.clearForm();
break;
}
}
@@ -0,0 +1,64 @@
<form class="auto-import" (submit)="$event.preventDefault()">
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.AUTO_DETECT.LABEL' | translate }}</mat-label>
<textarea
matInput
[formControl]="textControl"
rows="6"
data-test-id="auto-detect-textarea"
[placeholder]="'HOME.AUTO_DETECT.PLACEHOLDER' | translate"
></textarea>
</mat-form-field>
<p class="auto-import__privacy">
{{ 'HOME.AUTO_DETECT.PRIVACY_HINT' | translate }}
</p>
@if (hasText() && candidates().length === 0) {
<p class="auto-import__empty" data-test-id="auto-detect-no-matches">
{{ 'HOME.AUTO_DETECT.NO_MATCHES' | translate }}
</p>
}
@for (candidate of candidates(); track $index) {
<div class="auto-import__candidate" data-test-id="auto-detect-candidate">
<div class="auto-import__candidate-head">
<mat-icon class="auto-import__candidate-icon">
{{ kindIcon(candidate.kind) }}
</mat-icon>
<span class="auto-import__candidate-title">
{{ kindLabelKey(candidate.kind) | translate }}
</span>
<span
class="auto-import__confidence"
[class]="'auto-import__confidence--' + candidate.confidence"
>
{{ confidenceLabelKey(candidate.confidence) | translate }}
</span>
<button
mat-stroked-button
color="primary"
type="button"
data-test-id="auto-detect-use"
(click)="selectCandidate(candidate)"
>
{{ 'HOME.AUTO_DETECT.USE' | translate }}
</button>
</div>
@if (summaryRows(candidate).length > 0) {
<dl class="auto-import__rows">
@for (row of summaryRows(candidate); track row.labelKey) {
<div class="auto-import__row">
<dt>{{ row.labelKey | translate }}</dt>
<dd>{{ row.value }}</dd>
</div>
}
</dl>
} @else {
<p class="auto-import__candidate-note">
{{ 'HOME.AUTO_DETECT.M3U_TEXT_NOTE' | translate }}
</p>
}
</div>
}
</form>
@@ -0,0 +1,98 @@
:host {
display: flex;
margin: 10px;
justify-content: center;
}
.auto-import {
width: 100%;
}
.auto-import__privacy {
margin: 0 0 12px;
font-size: 12px;
color: var(--app-muted-color);
line-height: 1.45;
}
.auto-import__empty {
margin: 0 0 8px;
font-size: 13px;
color: var(--app-muted-color);
}
.auto-import__candidate {
border: 1px solid var(--app-widget-header-border);
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 10px;
background: color-mix(in srgb, var(--app-shell-bg) 60%, transparent);
}
.auto-import__candidate-head {
display: flex;
align-items: center;
gap: 8px;
}
.auto-import__candidate-icon {
color: var(--app-selection-color);
flex-shrink: 0;
}
.auto-import__candidate-title {
font-weight: 600;
color: var(--app-heading-color);
}
.auto-import__confidence {
font-size: 11px;
border-radius: 999px;
padding: 2px 8px;
border: 1px solid var(--app-widget-header-border);
color: var(--app-muted-color);
margin-right: auto;
white-space: nowrap;
}
.auto-import__confidence--high {
color: var(--app-selection-color);
border-color: color-mix(
in srgb,
var(--app-selection-color) 45%,
transparent
);
}
.auto-import__rows {
margin: 10px 0 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.auto-import__row {
display: flex;
gap: 10px;
min-width: 0;
font-size: 12.5px;
dt {
flex: 0 0 130px;
color: var(--app-muted-color);
}
dd {
margin: 0;
color: var(--app-body-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.auto-import__candidate-note {
margin: 8px 0 0;
font-size: 12.5px;
color: var(--app-muted-color);
}
@@ -0,0 +1,152 @@
import { Component, computed, output } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import {
detectProviderImportCandidates,
ProviderImportCandidate,
ProviderImportConfidence,
ProviderImportKind,
} from '@iptvnator/shared/interfaces';
interface CandidateSummaryRow {
labelKey: string;
value: string;
}
const KIND_ICONS: Record<ProviderImportKind, string> = {
xtream: 'vpn_key',
stalker: 'cast',
'm3u-url': 'public',
'm3u-text': 'subject',
};
const KIND_LABEL_KEYS: Record<ProviderImportKind, string> = {
xtream: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_LABEL',
stalker: 'HOME.ADD_PLAYLIST.METHOD_STALKER_LABEL',
'm3u-url': 'HOME.ADD_PLAYLIST.METHOD_URL_LABEL',
'm3u-text': 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL',
};
const CONFIDENCE_LABEL_KEYS: Record<ProviderImportConfidence, string> = {
high: 'HOME.AUTO_DETECT.CONFIDENCE_HIGH',
medium: 'HOME.AUTO_DETECT.CONFIDENCE_MEDIUM',
low: 'HOME.AUTO_DETECT.CONFIDENCE_LOW',
};
/**
* "Paste anything from your provider" surface: a free-text area run through
* the deterministic `detectProviderImportCandidates` on every edit. Each
* recognized source renders as a card; picking one hands the candidate to the
* dialog, which switches to the matching import form with the fields
* prefilled. Detection never adds a playlist by itself — the existing forms
* (and their behavioral probes) stay the single path into the store.
*/
@Component({
selector: 'app-auto-import',
templateUrl: './auto-import.component.html',
styleUrl: './auto-import.component.scss',
imports: [
MatButtonModule,
MatFormFieldModule,
MatIcon,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
})
export class AutoImportComponent {
readonly candidateSelected = output<ProviderImportCandidate>();
readonly textControl = new FormControl('', { nonNullable: true });
private readonly textValue = toSignal(this.textControl.valueChanges, {
initialValue: '',
});
readonly candidates = computed(() =>
detectProviderImportCandidates(this.textValue())
);
readonly hasText = computed(() => this.textValue().trim().length > 0);
clearForm(): void {
this.textControl.setValue('');
}
selectCandidate(candidate: ProviderImportCandidate): void {
this.candidateSelected.emit(candidate);
}
kindIcon(kind: ProviderImportKind): string {
return KIND_ICONS[kind];
}
kindLabelKey(kind: ProviderImportKind): string {
return KIND_LABEL_KEYS[kind];
}
confidenceLabelKey(confidence: ProviderImportConfidence): string {
return CONFIDENCE_LABEL_KEYS[confidence];
}
/**
* Rows shown on a candidate card. Labels reuse the import forms' own
* translation keys so the card vocabulary matches the form it prefills.
* The password is never rendered — its presence is stated with a mask.
*/
summaryRows(candidate: ProviderImportCandidate): CandidateSummaryRow[] {
const rows: CandidateSummaryRow[] = [];
const push = (labelKey: string, value: string | undefined) => {
if (value) {
rows.push({ labelKey, value });
}
};
switch (candidate.kind) {
case 'm3u-url':
push('HOME.URL_UPLOAD.PLAYLIST_URL', candidate.url);
break;
case 'xtream':
push(
'HOME.XTREAM_PLAYLIST.SERVER_URL',
candidate.serverUrl
);
push('HOME.XTREAM_PLAYLIST.USERNAME', candidate.username);
push(
'HOME.XTREAM_PLAYLIST.PASSWORD',
candidate.password ? '••••••' : undefined
);
break;
case 'stalker':
push(
'HOME.STALKER_PORTAL.SERVER_URL',
candidate.portalUrl
);
push(
'HOME.STALKER_PORTAL.MAC_ADDRESS',
candidate.macAddress
);
push(
'HOME.STALKER_PORTAL.SERIAL_NUMBER',
candidate.serialNumber
);
push('HOME.STALKER_PORTAL.DEVICE_ID_1', candidate.deviceId1);
push('HOME.STALKER_PORTAL.DEVICE_ID_2', candidate.deviceId2);
push('HOME.STALKER_PORTAL.SIGNATURE_1', candidate.signature1);
push('HOME.STALKER_PORTAL.SIGNATURE_2', candidate.signature2);
push('HOME.XTREAM_PLAYLIST.USERNAME', candidate.username);
push(
'HOME.XTREAM_PLAYLIST.PASSWORD',
candidate.password ? '••••••' : undefined
);
break;
case 'm3u-text':
break;
}
return rows;
}
}
@@ -1 +1,7 @@
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
export type PlaylistType =
| 'xtream'
| 'url'
| 'text'
| 'file'
| 'stalker'
| 'auto';
@@ -1,6 +1,12 @@
import { InjectionToken } from '@angular/core';
export type PortalPlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
export type PortalPlaylistType =
| 'xtream'
| 'url'
| 'text'
| 'file'
| 'stalker'
| 'auto';
export interface PortalShellActions {
openAddPlaylistDialog(type?: PortalPlaylistType): void;
+1
View File
@@ -35,6 +35,7 @@ export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
export * from './lib/playlist-display-label.util';
export * from './lib/portal-playback.interface';
export * from './lib/provider-import-detection.util';
export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/security-policy-error.utils';
@@ -0,0 +1,387 @@
import {
detectProviderImportCandidates,
ProviderImportCandidate,
} from './provider-import-detection.util';
describe('detectProviderImportCandidates', () => {
const only = (
candidates: ProviderImportCandidate[],
kind: ProviderImportCandidate['kind']
) => candidates.filter((candidate) => candidate.kind === kind);
it('returns nothing for empty or blank input', () => {
expect(detectProviderImportCandidates('')).toEqual([]);
expect(detectProviderImportCandidates(' \n\t ')).toEqual([]);
expect(
detectProviderImportCandidates(undefined as unknown as string)
).toEqual([]);
});
it('returns nothing for prose without credentials or links', () => {
expect(
detectProviderImportCandidates(
'Hello! Thanks for your purchase, enjoy the service.'
)
).toEqual([]);
});
describe('xtream detection', () => {
it('detects a get.php URL and mines the credentials from its query', () => {
const candidates = detectProviderImportCandidates(
'Your playlist: http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus&output=ts'
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
confidence: 'high',
serverUrl: 'http://tv.example.com:8080',
username: 'alice',
password: 's3cret',
suggestedTitle: 'tv.example.com',
});
});
it('offers a get.php link with credentials as a low-ranked plain M3U too', () => {
const candidates = detectProviderImportCandidates(
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
);
const m3u = only(candidates, 'm3u-url');
expect(m3u).toHaveLength(1);
expect(m3u[0].confidence).toBe('low');
expect(m3u[0].url).toBe(
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
);
});
it('combines a bare player_api.php URL with labeled credentials', () => {
const candidates = detectProviderImportCandidates(
[
'Server: http://panel.example.org/player_api.php',
'Username: bob',
'Password: hunter2',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
confidence: 'high',
serverUrl: 'http://panel.example.org',
username: 'bob',
password: 'hunter2',
});
});
it('leans xtream for labeled username+password with a labeled host and port', () => {
const candidates = detectProviderImportCandidates(
[
'Host: super.tv',
'Port: 8080',
'User: carol',
'Pass: pa55w0rd',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
confidence: 'medium',
serverUrl: 'http://super.tv:8080',
username: 'carol',
password: 'pa55w0rd',
suggestedTitle: 'super.tv',
});
});
it('still proposes xtream from a credential pair with no server at all', () => {
const candidates = detectProviderImportCandidates(
'login: dave\nпароль: qwerty123'
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0].confidence).toBe('low');
expect(xtream[0].username).toBe('dave');
expect(xtream[0].password).toBe('qwerty123');
expect(xtream[0].serverUrl).toBeUndefined();
});
it('understands Russian labels', () => {
const candidates = detectProviderImportCandidates(
[
'Сервер: http://ru.example.net:2500',
'Логин: ivan',
'Пароль: secret42',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0]).toMatchObject({
serverUrl: 'http://ru.example.net:2500',
username: 'ivan',
password: 'secret42',
});
});
it('treats credentials in the query of a generic URL as xtream', () => {
const candidates = detectProviderImportCandidates(
'http://portal.example.io/api?username=eve&password=pw&foo=bar'
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0].username).toBe('eve');
expect(xtream[0].password).toBe('pw');
});
});
describe('stalker detection', () => {
it('pairs a MAC with a portal-shaped URL at high confidence', () => {
const candidates = detectProviderImportCandidates(
[
'Portal: http://stb.example.com/stalker_portal/c/',
'MAC: 00:1A:79:12:34:56',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0]).toMatchObject({
confidence: 'high',
portalUrl: 'http://stb.example.com/stalker_portal/c/',
macAddress: '00:1A:79:12:34:56',
suggestedTitle: 'stb.example.com',
});
});
it('accepts /c/ and portal.php shaped URLs as portals', () => {
for (const url of [
'http://p.example.com/c/',
'http://p.example.com/portal.php',
'http://p.example.com/server/load.php',
]) {
const candidates = detectProviderImportCandidates(
`${url}\n00-1A-79-AB-CD-EF`
);
const stalker = only(candidates, 'stalker');
expect(stalker[0]?.portalUrl).toBe(url);
expect(stalker[0]?.confidence).toBe('high');
}
});
it('falls back to a generic URL at medium confidence', () => {
const candidates = detectProviderImportCandidates(
'http://somehost.example.net/tv\nmac: 00:1a:79:aa:bb:cc'
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0].confidence).toBe('medium');
expect(stalker[0].portalUrl).toBe('http://somehost.example.net/tv');
expect(stalker[0].macAddress).toBe('00:1A:79:AA:BB:CC');
});
it('proposes a low-confidence stalker candidate for a lone MAC', () => {
const candidates = detectProviderImportCandidates(
'Ваш MAC 00:1A:79:00:11:22 активирован'
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0].confidence).toBe('low');
expect(stalker[0].portalUrl).toBeUndefined();
});
it('recognizes a bare Infomir MAC but not a bare foreign 12-hex run', () => {
const infomir = detectProviderImportCandidates('mac 001a79deadbe');
expect(only(infomir, 'stalker')).toHaveLength(1);
expect(only(infomir, 'stalker')[0].macAddress).toBe(
'00:1A:79:DE:AD:BE'
);
const foreign = detectProviderImportCandidates(
'token aabbccddeeff issued'
);
expect(only(foreign, 'stalker')).toHaveLength(0);
});
it('accepts a labeled non-Infomir MAC', () => {
const candidates = detectProviderImportCandidates(
'MAC: A0:B1:C2:D3:E4:F5\nhttp://p.example.com/c/'
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0].macAddress).toBe('A0:B1:C2:D3:E4:F5');
});
it('captures serial, device IDs and signatures', () => {
const deviceId1 = 'a'.repeat(64);
const deviceId2 = 'b'.repeat(64);
const signature1 = 'c'.repeat(64);
const signature2 = 'd'.repeat(64);
const candidates = detectProviderImportCandidates(
[
'Portal: http://stb.example.com/c/',
'MAC: 00:1A:79:12:34:56',
'Serial Number: 0123456789AB',
`Device ID: ${deviceId1}`,
`Device ID 2: ${deviceId2}`,
`Signature: ${signature1}`,
`Signature 2: ${signature2}`,
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker[0]).toMatchObject({
serialNumber: '0123456789AB',
deviceId1,
deviceId2,
signature1,
signature2,
});
});
it('rejects non-hex device IDs and signatures instead of guessing', () => {
const candidates = detectProviderImportCandidates(
[
'MAC: 00:1A:79:12:34:56',
'Device ID: see-attachment',
'Signature: n/a',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker[0].deviceId1).toBeUndefined();
expect(stalker[0].signature1).toBeUndefined();
});
it('does not read a MAC out of a 64-hex device ID blob', () => {
const candidates = detectProviderImportCandidates(
`device id: ${'001a79'.repeat(10)}aabb`
);
expect(only(candidates, 'stalker')).toHaveLength(0);
});
it('produces one candidate per MAC when several are handed out', () => {
const candidates = detectProviderImportCandidates(
[
'http://multi.example.com/c/',
'00:1A:79:11:11:11',
'00:1A:79:22:22:22',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(2);
expect(stalker.map((candidate) => candidate.macAddress)).toEqual([
'00:1A:79:11:11:11',
'00:1A:79:22:22:22',
]);
});
it('gives labeled credentials to the stalker candidate, not a competing xtream guess', () => {
const candidates = detectProviderImportCandidates(
[
'Portal: http://stb.example.com/c/',
'MAC: 00:1A:79:12:34:56',
'Login: stbuser',
'Password: stbpass',
].join('\n')
);
expect(only(candidates, 'xtream')).toHaveLength(0);
const stalker = only(candidates, 'stalker');
expect(stalker[0].username).toBe('stbuser');
expect(stalker[0].password).toBe('stbpass');
});
});
describe('m3u detection', () => {
it('detects .m3u and .m3u8 links at high confidence', () => {
const candidates = detectProviderImportCandidates(
'List 1: https://lists.example.com/main.m3u\nList 2: https://lists.example.com/backup.m3u8'
);
const m3u = only(candidates, 'm3u-url');
expect(m3u).toHaveLength(2);
expect(m3u.every((c) => c.confidence === 'high')).toBe(true);
expect(m3u[0].url).toBe('https://lists.example.com/main.m3u');
expect(m3u[0].suggestedTitle).toBe('lists.example.com');
});
it('strips trailing punctuation from a link found in prose', () => {
const candidates = detectProviderImportCandidates(
'Your playlist (https://lists.example.com/main.m3u).'
);
expect(only(candidates, 'm3u-url')[0].url).toBe(
'https://lists.example.com/main.m3u'
);
});
it('does not turn an .m3u link with query credentials into an xtream server', () => {
const candidates = detectProviderImportCandidates(
'https://lists.example.com/files/list.m3u8?username=u&password=p'
);
expect(only(candidates, 'xtream')).toHaveLength(0);
expect(only(candidates, 'm3u-url')).toHaveLength(1);
});
it('routes a pasted playlist body to the raw-text import', () => {
const body =
'#EXTM3U\n#EXTINF:-1 tvg-id="one",Channel One\nhttp://streams.example.com/1.ts';
const candidates = detectProviderImportCandidates(body);
expect(candidates).toEqual([
{ kind: 'm3u-text', confidence: 'high', text: body },
]);
});
});
describe('mixed messages and ranking', () => {
it('surfaces every source found in a combined provider message', () => {
const candidates = detectProviderImportCandidates(
[
'Xtream: http://x.example.com:8080/get.php?username=u1&password=p1',
'Backup M3U: http://x.example.com:8080/files/backup.m3u',
'Stalker portal: http://s.example.com/c/',
'MAC: 00:1A:79:99:88:77',
].join('\n')
);
expect(only(candidates, 'stalker')).toHaveLength(1);
expect(only(candidates, 'xtream')).toHaveLength(1);
expect(only(candidates, 'm3u-url').length).toBeGreaterThanOrEqual(1);
// High-confidence candidates sort ahead of low-confidence ones.
const ranks = candidates.map((candidate) => candidate.confidence);
const firstLow = ranks.indexOf('low');
const lastHigh = ranks.lastIndexOf('high');
expect(firstLow === -1 || lastHigh < firstLow).toBe(true);
});
it('dedupes an URL pasted twice', () => {
const candidates = detectProviderImportCandidates(
'https://lists.example.com/main.m3u\nhttps://lists.example.com/main.m3u'
);
expect(only(candidates, 'm3u-url')).toHaveLength(1);
});
it('caps the number of returned candidates', () => {
const urls = Array.from(
{ length: 12 },
(_, index) => `https://lists.example.com/list-${index}.m3u`
).join('\n');
expect(
detectProviderImportCandidates(urls).length
).toBeLessThanOrEqual(6);
});
});
});
@@ -0,0 +1,280 @@
import {
DetectedUrl,
extractLabeledFields,
extractMacAddresses,
extractUrls,
LabeledFields,
labeledHostUrl,
} from './provider-import-scan.util';
import {
extractXtreamCredentialsFromUrl,
normalizeXtreamServerUrl,
} from './xtream-portal.utils';
/**
* Deterministic detection of playlist sources inside a pasted provider
* message ("here are your credentials" mails, reseller chat messages, etc.).
*
* Design constraints, in order:
*
* 1. **Every value is a verbatim substring of the input** (or a canonical
* form produced by an existing shared normalizer such as
* `normalizeStalkerMacAddress`). Nothing here invents, completes, or
* "fixes" a credential — a password that is almost right is wrong.
* 2. **Detection proposes, it never decides.** The output prefills the
* existing import forms; classification authority stays with the
* behavioral probes those forms already run (portal discovery, Xtream
* status check). URL-shape guessing shipped broken Stalker configs three
* times (#850/#686/#755) — this util must not become a fourth copy.
* 3. Heuristics are ranked by strength: a MAC address is the strongest
* signal (→ Stalker), a `get.php`/`player_api.php` URL is next
* (→ Xtream, credentials ride in the query), a labeled
* username+password pair without a MAC leans Xtream, and `.m3u`
* extensions mean a plain playlist URL.
*
* The low-level scanners live in `provider-import-scan.util.ts`.
*/
export type ProviderImportKind = 'xtream' | 'stalker' | 'm3u-url' | 'm3u-text';
export type ProviderImportConfidence = 'high' | 'medium' | 'low';
export interface ProviderImportCandidate {
kind: ProviderImportKind;
confidence: ProviderImportConfidence;
/** Suggested playlist title — the hostname of the detected source. */
suggestedTitle?: string;
/** `m3u-url`: the playlist link exactly as found in the text. */
url?: string;
/** `m3u-text`: the pasted playlist body itself. */
text?: string;
/** `xtream`: server base URL (API suffix stripped). */
serverUrl?: string;
username?: string;
password?: string;
/** `stalker` */
portalUrl?: string;
macAddress?: string;
serialNumber?: string;
deviceId1?: string;
deviceId2?: string;
signature1?: string;
signature2?: string;
}
const GET_PHP_PATH_PATTERN = /\/get\.php$/i;
const EXTM3U_HEADER_PATTERN = /^\s*#EXTM3U/i;
const MAX_CANDIDATES = 6;
/**
* Scans a pasted provider message and returns ranked import candidates.
* Pure and synchronous — safe to run on every keystroke.
*/
export function detectProviderImportCandidates(
text: string
): ProviderImportCandidate[] {
const raw = typeof text === 'string' ? text : '';
if (!raw.trim()) {
return [];
}
// A raw playlist body pasted wholesale: every URL inside is a channel
// stream, not a source, so URL mining would only produce noise. Route the
// whole paste to the raw-text import instead.
if (EXTM3U_HEADER_PATTERN.test(raw)) {
return [{ kind: 'm3u-text', confidence: 'high', text: raw }];
}
const urls = extractUrls(raw);
const labeled = extractLabeledFields(raw, urls);
const macs = extractMacAddresses(raw, labeled.macAddress);
const candidates: ProviderImportCandidate[] = [];
// ── Stalker: a MAC address is the strongest signal there is. Labeled
// credentials attach here (Stalker portals can require a login too)
// rather than seeding a competing Xtream guess.
const portal = pickStalkerPortalUrl(urls, labeled);
for (const macAddress of macs) {
candidates.push(
compact({
kind: 'stalker',
confidence: portal?.confidence ?? 'low',
portalUrl: portal?.url,
macAddress,
serialNumber: labeled.serialNumber,
deviceId1: labeled.deviceId1,
deviceId2: labeled.deviceId2,
signature1: labeled.signature1,
signature2: labeled.signature2,
username: labeled.username,
password: labeled.password,
suggestedTitle: hostnameOf(portal?.url),
})
);
}
// ── Xtream from URLs: an API-shaped path, or credentials in the query of
// any non-playlist URL. A `.m3u` link with query credentials stays an M3U
// candidate — its path would corrupt the derived server URL.
for (const url of urls) {
if (url.role === 'm3u' || url.role === 'stalker') {
continue;
}
const creds = extractXtreamCredentialsFromUrl(url.raw);
if (url.role !== 'xtream-api' && !creds) {
continue;
}
const username =
creds?.username ??
(macs.length === 0 ? labeled.username : undefined);
const password =
creds?.password ??
(macs.length === 0 ? labeled.password : undefined);
candidates.push(
compact({
kind: 'xtream',
confidence: username && password ? 'high' : 'medium',
serverUrl: safeXtreamServerUrl(url.raw),
username,
password,
suggestedTitle: url.parsed.hostname,
})
);
// `get.php` doubles as the M3U download endpoint, so the same link is
// honestly offered as a plain playlist too — ranked low because the
// Xtream import unlocks VOD/series where the flat M3U does not.
if (creds && GET_PHP_PATH_PATTERN.test(url.parsed.pathname)) {
candidates.push({
kind: 'm3u-url',
confidence: 'low',
url: url.raw,
suggestedTitle: url.parsed.hostname,
});
}
}
// ── Xtream from labeled credentials alone (username + password with no
// MAC in sight leans Xtream), attached to the best available server URL.
if (
macs.length === 0 &&
labeled.username &&
labeled.password &&
!candidates.some((candidate) => candidate.kind === 'xtream')
) {
const serverUrl =
labeledHostUrl(labeled) ??
urls.find((url) => url.role === 'generic')?.raw;
candidates.push(
compact({
kind: 'xtream',
confidence: serverUrl ? 'medium' : 'low',
serverUrl: serverUrl
? safeXtreamServerUrl(serverUrl)
: undefined,
username: labeled.username,
password: labeled.password,
suggestedTitle: hostnameOf(serverUrl),
})
);
}
// ── Plain playlist links.
for (const url of urls) {
if (url.role !== 'm3u') {
continue;
}
candidates.push({
kind: 'm3u-url',
confidence: 'high',
url: url.raw,
suggestedTitle: url.parsed.hostname,
});
}
return sortByConfidence(dedupe(candidates)).slice(0, MAX_CANDIDATES);
}
function pickStalkerPortalUrl(
urls: DetectedUrl[],
labeled: LabeledFields
): { url: string; confidence: ProviderImportConfidence } | null {
const shaped = urls.find((url) => url.role === 'stalker');
if (shaped) {
return { url: shaped.raw, confidence: 'high' };
}
const labeledUrl = labeledHostUrl(labeled);
if (labeledUrl) {
return { url: labeledUrl, confidence: 'high' };
}
const generic = urls.find((url) => url.role === 'generic');
if (generic) {
return { url: generic.raw, confidence: 'medium' };
}
return null;
}
function safeXtreamServerUrl(raw: string): string | undefined {
try {
return normalizeXtreamServerUrl(raw);
} catch {
return undefined;
}
}
function hostnameOf(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
/** Drops undefined/empty fields so candidates stay clean to render and diff. */
function compact(candidate: ProviderImportCandidate): ProviderImportCandidate {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(candidate)) {
if (value !== undefined && value !== '') {
result[key] = value;
}
}
return result as unknown as ProviderImportCandidate;
}
function dedupe(
candidates: ProviderImportCandidate[]
): ProviderImportCandidate[] {
const seen = new Set<string>();
return candidates.filter((candidate) => {
const key = [
candidate.kind,
candidate.url ?? '',
candidate.serverUrl ?? '',
candidate.username ?? '',
candidate.portalUrl ?? '',
candidate.macAddress ?? '',
].join('|');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
const CONFIDENCE_RANK: Record<ProviderImportConfidence, number> = {
high: 0,
medium: 1,
low: 2,
};
function sortByConfidence(
candidates: ProviderImportCandidate[]
): ProviderImportCandidate[] {
// Array.prototype.sort is stable, so equal-confidence candidates keep
// their assembly order: Stalker (strongest signal) → Xtream → M3U.
return [...candidates].sort(
(a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]
);
}
@@ -0,0 +1,308 @@
import { normalizeStalkerMacAddress } from './stalker-mac-address.util';
import { isFullStalkerPortalUrl } from './stalker-portal-mode.util';
/**
* Low-level scanners for the provider-message auto-detect
* (`provider-import-detection.util.ts` assembles their findings into
* candidates). Everything here returns verbatim substrings of the input, or
* canonical forms produced by the existing shared normalizers.
*/
export type UrlRole = 'xtream-api' | 'm3u' | 'stalker' | 'generic';
export interface DetectedUrl {
/** Exact substring as found, trailing punctuation stripped. */
raw: string;
parsed: URL;
role: UrlRole;
}
export interface LabeledFields {
username?: string;
password?: string;
/** Raw value of a server/portal/host/url label — URL- or host-shaped. */
host?: string;
port?: string;
macAddress?: string;
serialNumber?: string;
deviceId1?: string;
deviceId2?: string;
signature1?: string;
signature2?: string;
}
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/;
const M3U_PATH_PATTERN = /\.m3u8?$/i;
const XTREAM_API_PATH_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
const STALKER_PATH_PATTERN = /\/(?:portal\.php|c\/?)$/i;
// Uniform-separator MAC (00:1A:79:AA:BB:CC, hyphens or dots alike). The
// backreference rejects mixed separators, and because `\b` never sits between
// two word characters a MAC-shaped window inside a longer hex blob (a 64-hex
// device ID) cannot match.
const SEPARATED_MAC_PATTERN = /\b[0-9A-F]{2}([:.-])[0-9A-F]{2}(?:\1[0-9A-F]{2}){4}\b/gi;
// A bare 12-hex run is only trusted as a MAC when it carries Infomir's OUI —
// any 12 hex digits would false-positive on ids, tokens and hashes.
const BARE_INFOMIR_MAC_PATTERN = /\b001A79[0-9A-F]{6}\b/gi;
/** Hex-shaped identity values (device IDs / signatures are SHA-256 hex). */
const HEX_IDENTITY_PATTERN = /^[0-9A-Fa-f]{16,64}$/;
const HOSTISH_PATTERN = /^[a-z0-9][a-z0-9.-]*(?::\d{2,5})?$/i;
const WRAPPING_CHARS = /^["'`«<([]+|["'`»>)\]]+$/g;
const MAX_URLS = 8;
const MAX_MACS = 4;
interface FieldMatcher {
field: keyof LabeledFields;
pattern: RegExp;
validate?: (value: string) => boolean;
}
// Order matters only for readability — each matcher targets its own field and
// the numbered variants ("device id 2") are structured so the unnumbered
// pattern cannot swallow them (its optional `1` never matches a literal `2`,
// and the required `:`/`=` separator then fails on the digit).
//
// All matchers require an explicit `:` or `=` separator. Provider messages
// overwhelmingly use "Label: value"; a bare "login example" in prose is
// exactly the false positive the separator rule exists to reject. The label
// vocabulary is deliberately small (English + Russian) until a corpus of
// real provider messages justifies growing it.
const FIELD_MATCHERS: FieldMatcher[] = [
{
field: 'deviceId2',
pattern:
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'deviceId1',
pattern:
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'signature2',
pattern:
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'signature1',
pattern:
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
},
{
field: 'serialNumber',
pattern:
/(?<![\p{L}\p{N}_])(?:serial[\s_-]*(?:number|no)?|s\/n|sn|серийный(?:[\s_-]*номер)?)\s*[:=]\s*([^\s,;|]+)/iu,
validate: (value) => value.length <= 64,
},
{
field: 'macAddress',
pattern:
/(?<![\p{L}\p{N}_])(?:mac(?:[\s_-]*address)?|мак(?:[\s_-]*адрес)?)\s*[:=]\s*([0-9A-Fa-f:. -]{12,23})/iu,
validate: (value) => normalizeStalkerMacAddress(value) !== null,
},
{
field: 'username',
pattern:
/(?<![\p{L}\p{N}_])(?:user[\s_-]*name|login|user|логин|пользователь)\s*[:=]\s*([^\s,;|]+)/iu,
},
{
field: 'password',
pattern:
/(?<![\p{L}\p{N}_])(?:pass[\s_-]*word|pass|pwd|пароль)\s*[:=]\s*([^\s,;|]+)/iu,
},
{
field: 'host',
pattern:
/(?<![\p{L}\p{N}_])(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?\s*[:=]\s*([^\s,;|]+)/iu,
},
{
field: 'port',
pattern: /(?<![\p{L}\p{N}_])(?:port|порт)\s*[:=]\s*(\d{2,5})\b/iu,
},
];
/**
* `String.prototype.matchAll` equivalent — the web build's TS lib target
* predates it. Works on a fresh RegExp copy so the module-level patterns
* never carry `lastIndex` state between calls.
*/
function allMatches(pattern: RegExp, text: string): RegExpExecArray[] {
const matcher = new RegExp(pattern.source, pattern.flags);
const matches: RegExpExecArray[] = [];
let match: RegExpExecArray | null;
while ((match = matcher.exec(text)) !== null) {
matches.push(match);
if (match[0] === '') {
matcher.lastIndex += 1;
}
}
return matches;
}
export function extractUrls(text: string): DetectedUrl[] {
const detected: DetectedUrl[] = [];
const seen = new Set<string>();
for (const match of allMatches(URL_PATTERN, text)) {
const raw = match[0].replace(TRAILING_PUNCTUATION, '');
if (seen.has(raw)) {
continue;
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
continue;
}
seen.add(raw);
detected.push({ raw, parsed, role: classifyUrl(raw, parsed) });
if (detected.length >= MAX_URLS) {
break;
}
}
return detected;
}
function classifyUrl(raw: string, parsed: URL): UrlRole {
if (M3U_PATH_PATTERN.test(parsed.pathname)) {
return 'm3u';
}
if (XTREAM_API_PATH_PATTERN.test(parsed.pathname)) {
return 'xtream-api';
}
if (
isFullStalkerPortalUrl(raw) ||
STALKER_PATH_PATTERN.test(parsed.pathname)
) {
return 'stalker';
}
return 'generic';
}
export function extractLabeledFields(
text: string,
urls: DetectedUrl[]
): LabeledFields {
// Query strings ARE label-shaped (`?username=u&password=p`), so labeled
// extraction must never look inside a URL: credentials that ride in a
// query are mined by `extractXtreamCredentialsFromUrl` with proper URL
// decoding, and reading them as plain labels would truncate at `&`. Every
// matcher therefore runs against a copy with URL spans blanked out — with
// one exception: the host/server label legitimately points AT a URL
// ("Portal: http://…"), so it reads the raw text, accepting only matches
// whose label starts outside every URL span (`&url=…` inside a query
// stays rejected).
const spans = urlSpans(text, urls);
const masked = maskSpans(text, spans);
const fields: LabeledFields = {};
for (const { field, pattern, validate } of FIELD_MATCHERS) {
if (fields[field] !== undefined) {
continue;
}
const match = pattern.exec(field === 'host' ? text : masked);
if (!match) {
continue;
}
if (field === 'host' && isInsideSpan(spans, match.index)) {
continue;
}
const value = stripWrapping(match[1] ?? '');
if (!value || (validate && !validate(value))) {
continue;
}
fields[field] = value;
}
return fields;
}
export function extractMacAddresses(
text: string,
labeledMac: string | undefined
): string[] {
const macs: string[] = [];
const push = (value: string | null) => {
if (value && !macs.includes(value)) {
macs.push(value);
}
};
push(normalizeStalkerMacAddress(labeledMac));
for (const match of allMatches(SEPARATED_MAC_PATTERN, text)) {
push(normalizeStalkerMacAddress(match[0]));
}
for (const match of allMatches(BARE_INFOMIR_MAC_PATTERN, text)) {
push(normalizeStalkerMacAddress(match[0]));
}
return macs.slice(0, MAX_MACS);
}
/**
* Turns a labeled server/host value into an http URL. A scheme-less host is
* completed with `http://` and a separately labeled port — the one place a
* value is assembled rather than found verbatim, and it lands in an editable
* form field, never on the wire directly.
*/
export function labeledHostUrl(labeled: LabeledFields): string | undefined {
const host = labeled.host;
if (!host) {
return undefined;
}
if (/^https?:\/\//i.test(host)) {
return host;
}
if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) {
return undefined;
}
const portSuffix =
host.includes(':') || !labeled.port ? '' : `:${labeled.port}`;
return `http://${host}${portSuffix}`;
}
function urlSpans(
text: string,
urls: DetectedUrl[]
): Array<[number, number]> {
const spans: Array<[number, number]> = [];
for (const url of urls) {
let from = 0;
for (;;) {
const index = text.indexOf(url.raw, from);
if (index === -1) {
break;
}
spans.push([index, index + url.raw.length]);
from = index + url.raw.length;
}
}
return spans;
}
function maskSpans(text: string, spans: Array<[number, number]>): string {
if (spans.length === 0) {
return text;
}
const chars = text.split('');
for (const [start, end] of spans) {
for (let index = start; index < end; index += 1) {
chars[index] = ' ';
}
}
return chars.join('');
}
function isInsideSpan(
spans: Array<[number, number]>,
index: number
): boolean {
return spans.some(([start, end]) => index > start && index < end);
}
function stripWrapping(value: string): string {
return value.trim().replace(WRAPPING_CHARS, '').trim();
}
@@ -9,7 +9,8 @@ export type WorkspacePlaylistType =
| 'url'
| 'text'
| 'file'
| 'stalker';
| 'stalker'
| 'auto';
export type PlaylistCategory = 'm3u' | 'xtream' | 'stalker';
export type M3uSubType = 'url' | 'file' | 'text';