diff --git a/.changes/playlist-auto-detect-import.md b/.changes/playlist-auto-detect-import.md new file mode 100644 index 000000000..da8dbf145 --- /dev/null +++ b/.changes/playlist-auto-detect-import.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 541b6a7da..dcc2480ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ce6b49cbb..3d4dd5edb 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "الإعدادات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 2e5cc5a32..f286c8613 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "الإعدادات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7f2cc8fd1..f0ed836a5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Налады", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index eb249ca1b..32857c7b8 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a8e7327a3..7922b4783 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Ρυθμίσεις", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3695ede59..7495f5e0e 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 207523bf6..3f41f1016 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 478033a26..5eb9975f9 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 52fa198b2..b975e4a8b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 76fd6f92c..5be32a5e4 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index fc8013ccc..3957decaa 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "設定", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 76e5e7cd2..90ac07f71 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "설정", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index bdde36e4f..7a9edb51c 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1ed4a1a73..18328a79d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 8ad9c698c..64a172de5 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c32c5af69..a38f1f662 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Настройки", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 1c2b6ee82..b6911490b 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index fa9691c4b..898aef474 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "设置", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 3b290bf9e..a121ed420 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "設定", diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index 6bb2389e3..2bbfceb50 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -57,6 +57,11 @@ @case ('xtream') { } + @case ('auto') { + + } } diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss index 799fe67c0..7ae4bc42a 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss @@ -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) { diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts index ff90595af..8e55c858c 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -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); } }); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index d989f2355..bdea15172 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -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('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(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; } } diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.html b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.html new file mode 100644 index 000000000..ed4cbc7a0 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.html @@ -0,0 +1,64 @@ +
+ + {{ 'HOME.AUTO_DETECT.LABEL' | translate }} + + + +

+ {{ 'HOME.AUTO_DETECT.PRIVACY_HINT' | translate }} +

+ + @if (hasText() && candidates().length === 0) { +

+ {{ 'HOME.AUTO_DETECT.NO_MATCHES' | translate }} +

+ } + + @for (candidate of candidates(); track $index) { +
+
+ + {{ kindIcon(candidate.kind) }} + + + {{ kindLabelKey(candidate.kind) | translate }} + + + {{ confidenceLabelKey(candidate.confidence) | translate }} + + +
+ @if (summaryRows(candidate).length > 0) { +
+ @for (row of summaryRows(candidate); track row.labelKey) { +
+
{{ row.labelKey | translate }}
+
{{ row.value }}
+
+ } +
+ } @else { +

+ {{ 'HOME.AUTO_DETECT.M3U_TEXT_NOTE' | translate }} +

+ } +
+ } +
diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.scss b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.scss new file mode 100644 index 000000000..1e203e390 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.scss @@ -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); +} diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts new file mode 100644 index 000000000..5c86bd4a7 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -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 = { + xtream: 'vpn_key', + stalker: 'cast', + 'm3u-url': 'public', + 'm3u-text': 'subject', +}; + +const KIND_LABEL_KEYS: Record = { + 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 = { + 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(); + + 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; + } +} diff --git a/libs/playlist/shared/ui/src/lib/add-playlist-menu/playlist-type.ts b/libs/playlist/shared/ui/src/lib/add-playlist-menu/playlist-type.ts index 2a9fa16a2..5ba4ce014 100644 --- a/libs/playlist/shared/ui/src/lib/add-playlist-menu/playlist-type.ts +++ b/libs/playlist/shared/ui/src/lib/add-playlist-menu/playlist-type.ts @@ -1 +1,7 @@ -export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker'; +export type PlaylistType = + | 'xtream' + | 'url' + | 'text' + | 'file' + | 'stalker' + | 'auto'; diff --git a/libs/portal/shared/util/src/lib/portal-shell-actions.ts b/libs/portal/shared/util/src/lib/portal-shell-actions.ts index d1898edb5..a9e461310 100644 --- a/libs/portal/shared/util/src/lib/portal-shell-actions.ts +++ b/libs/portal/shared/util/src/lib/portal-shell-actions.ts @@ -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; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 1328de28b..315ad76c8 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts new file mode 100644 index 000000000..a1bac8296 --- /dev/null +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -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); + }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts new file mode 100644 index 000000000..1db9b76cc --- /dev/null +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -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 = {}; + 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(); + 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 = { + 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] + ); +} diff --git a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts new file mode 100644 index 000000000..14365bd9f --- /dev/null +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -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: + /(? HEX_IDENTITY_PATTERN.test(value), + }, + { + field: 'deviceId1', + pattern: + /(? HEX_IDENTITY_PATTERN.test(value), + }, + { + field: 'signature2', + pattern: + /(? HEX_IDENTITY_PATTERN.test(value), + }, + { + field: 'signature1', + pattern: + /(? HEX_IDENTITY_PATTERN.test(value), + }, + { + field: 'serialNumber', + pattern: + /(? value.length <= 64, + }, + { + field: 'macAddress', + pattern: + /(? normalizeStalkerMacAddress(value) !== null, + }, + { + field: 'username', + pattern: + /(?(); + 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(); +} diff --git a/libs/workspace/shell/util/src/lib/workspace-shell-actions.ts b/libs/workspace/shell/util/src/lib/workspace-shell-actions.ts index 2bbe4ceb9..196bf34a1 100644 --- a/libs/workspace/shell/util/src/lib/workspace-shell-actions.ts +++ b/libs/workspace/shell/util/src/lib/workspace-shell-actions.ts @@ -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';