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 43b1c1aae..69953fa05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -303,7 +303,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-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index 8fa6d4d13..c8f63bff6 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -31,6 +31,85 @@ test('@web @m3u basic playlist import flow', async ({ page }) => { await expect(page.getByText('4. HappyKids TV')).toBeVisible(); }); +test('@web @auto-detect pasted provider message prefills the Xtream form', async ({ + page, +}) => { + await page.goto('/'); + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Auto-detect/i }).click(); + + // Shape of a real reseller handout, with fictional credentials. + await dialog + .locator('[data-test-id="auto-detect-textarea"]') + .fill( + '◉𝙿𝙾𝚁𝚃𝙰𝙻➤ http://tv.example.com:8080\n' + + '├◉𝚄𝚂𝙴𝚁➤ e2euser\n' + + '├◉𝙿𝙰𝚂𝚂➤ e2epass' + ); + + const candidate = dialog.locator('[data-test-id="auto-detect-candidate"]').first(); + await expect(candidate).toBeVisible(); + await expect(candidate.getByText('e2euser')).toBeVisible(); + // The card must never print the password in clear. + await expect(candidate.getByText('e2epass')).toHaveCount(0); + + await candidate.locator('[data-test-id="auto-detect-use"]').click(); + + // The dialog switches to the real Xtream form (@switch + viewChild + // timing) and the prefill lands in its controls. + await expect( + dialog.getByRole('radio', { name: /Xtream credentials/i }) + ).toBeChecked(); + await expect(dialog.getByLabel('Server URL')).toHaveValue( + 'http://tv.example.com:8080' + ); + await expect(dialog.getByLabel('Username')).toHaveValue('e2euser'); + await expect(dialog.getByLabel('Password')).toHaveValue('e2epass'); + await expect(dialog.getByLabel('Playlist title')).toHaveValue( + 'tv.example.com' + ); + + // Detection only proposes: the regular Add action stays in charge. + await expect( + dialog.getByRole('button', { name: 'Add', exact: true }) + ).toBeEnabled(); +}); + +test('@web @auto-detect keeps the pasted message when switching methods', async ({ + page, +}) => { + await page.goto('/'); + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await dialog.getByRole('radio', { name: /Auto-detect/i }).click(); + + const message = 'Portal: http://stb.example.com/c/\nMAC: 00:1A:79:12:34:56'; + await dialog.locator('[data-test-id="auto-detect-textarea"]').fill(message); + + const candidate = dialog.locator('[data-test-id="auto-detect-candidate"]').first(); + await expect(candidate.getByText('00:1A:79:12:34:56')).toBeVisible(); + await candidate.locator('[data-test-id="auto-detect-use"]').click(); + + await expect( + dialog.getByRole('radio', { name: /Stalker portal/i }) + ).toBeChecked(); + // Exact: the derive-device-IDs checkbox is labelled "Generate device IDs + // from the MAC address", which a substring match would also resolve to. + await expect( + dialog.getByLabel('Mac Address', { exact: true }) + ).toHaveValue('00:1A:79:12:34:56'); + + // Returning to auto-detect must not cost the user their paste. + await dialog.getByRole('radio', { name: /Auto-detect/i }).click(); + await expect(dialog.locator('[data-test-id="auto-detect-textarea"]')).toHaveValue( + message + ); +}); + test('@web keyboard shortcuts help opens from question mark', async ({ page }) => { await page.goto('/'); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 43e8d7b5d..6d076c95b 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 6e01bbf00..52d1be9e7 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 403d55e95..f35cbb2e2 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 59a3852f6..e4c2e7235 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 4797110de..71fa4db7b 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 862a168be..318b25804 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 e81359922..c5d49ecc4 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 74c302ca7..f0142dade 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 410404d57..930f864c8 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 6f3054035..3bc9668c7 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 a1e8ab00f..0639f614e 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 1119b4902..f39277fa8 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 a05d95811..052bf870c 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 ed04408a7..c8bab37f0 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 e2d9889b7..3c72bc554 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 f718dd3a6..35c3cd533 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 49057eeb8..d291f856e 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 f1fdcd086..6551772a8 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 02801cd07..6f2170874 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..ea6305e60 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,13 @@ @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..2b3a42252 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,180 @@ 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('drops the candidate when the user switches to another method first', () => { + const patchValue = jest.fn(); + (component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn( + () => ({ form: { patchValue } }) + ); + + component.onCandidateSelected({ + kind: 'xtream', + confidence: 'high', + username: 'alice', + }); + // The user clicks another tile before the xtream form mounted. + component.method.set('file'); + applyPrefill(); + + // Coming back to the xtream form later must not prefill it. + component.method.set('xtream'); + applyPrefill(); + + expect(patchValue).not.toHaveBeenCalled(); + }); + + 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..24065dea8 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,26 @@ 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); + + /** + * Survives the auto-detect surface being destroyed by a method switch, so + * returning to it after inspecting a prefilled form keeps the paste. + */ + readonly autoDetectText = signal(''); + // 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 +144,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 +162,87 @@ 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; + } + // The user can click another method tile before the target form + // mounts; a candidate must not lie in wait and prefill a later visit + // to its form. Only the method the selection itself switched to may + // consume it. + if (this.method() !== METHOD_BY_CANDIDATE_KIND[candidate.kind]) { + this.pendingPrefill.set(null); + 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 +317,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..f52ee6ed4 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.scss @@ -0,0 +1,102 @@ +: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; + // Narrow phone dialogs (~320–375px) cannot fit icon + title + badge + + // action on one line; wrapping the action below beats horizontal clipping. + flex-wrap: wrap; + row-gap: 6px; +} + +.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.spec.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts new file mode 100644 index 000000000..e6d7822aa --- /dev/null +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts @@ -0,0 +1,147 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { ProviderImportCandidate } from '@iptvnator/shared/interfaces'; +import { AutoImportComponent } from './auto-import.component'; + +describe('AutoImportComponent', () => { + let component: AutoImportComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [AutoImportComponent, TranslateModule.forRoot()], + providers: [provideNoopAnimations()], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AutoImportComponent); + component = fixture.componentInstance; + }); + + it('computes candidates from the pasted text', () => { + fixture.detectChanges(); + + component.textControl.setValue( + 'https://lists.example.com/main.m3u' + ); + + expect(component.candidates()).toHaveLength(1); + expect(component.candidates()[0].kind).toBe('m3u-url'); + }); + + it('restores the dialog-held text so a method switch does not lose the paste', () => { + fixture.componentRef.setInput( + 'initialText', + 'MAC: 00:1A:79:12:34:56' + ); + + fixture.detectChanges(); + + expect(component.textControl.value).toBe('MAC: 00:1A:79:12:34:56'); + expect(component.candidates()[0]?.kind).toBe('stalker'); + }); + + it('reports edits back to the dialog', () => { + fixture.detectChanges(); + const emitted: string[] = []; + component.textChanged.subscribe((value) => emitted.push(value)); + + component.textControl.setValue('some text'); + + expect(emitted).toEqual(['some text']); + }); + + it('clears the textarea and the candidates', () => { + fixture.detectChanges(); + component.textControl.setValue('https://lists.example.com/main.m3u'); + + component.clearForm(); + + expect(component.textControl.value).toBe(''); + expect(component.candidates()).toHaveLength(0); + }); + + it('emits the picked candidate', () => { + fixture.detectChanges(); + const emitted: ProviderImportCandidate[] = []; + component.candidateSelected.subscribe((candidate) => + emitted.push(candidate) + ); + const candidate: ProviderImportCandidate = { + kind: 'xtream', + confidence: 'high', + username: 'alice', + }; + + component.selectCandidate(candidate); + + expect(emitted).toEqual([candidate]); + }); + + it('masks the query password of an M3U link on the card', () => { + fixture.detectChanges(); + + const rows = component.summaryRows({ + kind: 'm3u-url', + confidence: 'low', + url: 'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus', + }); + + expect(rows[0].value).toContain('username=alice'); + expect(rows[0].value).toContain('password=••••••'); + expect(JSON.stringify(rows)).not.toContain('s3cret'); + }); + + it('masks an HTTP Basic password embedded in the URL', () => { + fixture.detectChanges(); + + const rows = component.summaryRows({ + kind: 'm3u-url', + confidence: 'high', + url: 'https://alice:s3cret@lists.example.com/list.m3u', + }); + + expect(rows[0].value).toBe( + 'https://alice:••••••@lists.example.com/list.m3u' + ); + expect(rows[0].value).not.toContain('s3cret'); + }); + + it('masks percent-encoded and repeated password parameters', () => { + fixture.detectChanges(); + + const rows = component.summaryRows({ + kind: 'm3u-url', + confidence: 'low', + // `pass%77ord` is what URLSearchParams — and the importer — read + // as `password`, so the card must hide it too. + url: 'http://tv.example.com/get.php?pass%77ord=first&username=alice&password=second', + }); + + expect(rows[0].value).toContain('username=alice'); + expect(rows[0].value).not.toContain('first'); + expect(rows[0].value).not.toContain('second'); + expect(rows[0].value).toContain('pass%77ord=••••••'); + expect(rows[0].value).toContain('password=••••••'); + }); + + it('masks the password in the summary rows', () => { + fixture.detectChanges(); + + const rows = component.summaryRows({ + kind: 'xtream', + confidence: 'high', + serverUrl: 'http://tv.example.com:8080', + username: 'alice', + password: 's3cret', + }); + + const passwordRow = rows.find( + (row) => row.labelKey === 'HOME.XTREAM_PLAYLIST.PASSWORD' + ); + expect(passwordRow?.value).toBe('••••••'); + expect(JSON.stringify(rows)).not.toContain('s3cret'); + }); +}); 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..59641610e --- /dev/null +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -0,0 +1,269 @@ +import { Component, computed, input, OnInit, output } from '@angular/core'; +import { takeUntilDestroyed, 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', +}; + +/** + * Hides the value of every `password` query parameter for display, keeping + * the rest of the URL byte-identical so the user still recognizes their link. + * + * Works pair by pair on the raw query rather than through `URLSearchParams`, + * for two reasons: re-serializing would re-encode the whole URL (the mask + * itself included) into something unreadable, and every occurrence must be + * covered, not just the first. The parameter NAME is decoded before the + * comparison because `pass%77ord=` is what `URLSearchParams` — and therefore + * the importer — reads as `password`. + */ +function maskUrlPasswords(url: string): string { + return maskUrlQueryPasswords(maskUrlUserinfoPassword(url)); +} + +/** + * Hides the password half of HTTP Basic userinfo + * (`https://alice:secret@host/…`), which is a password on the card exactly + * like a query one. The username stays visible, as it does everywhere else. + */ +function maskUrlUserinfoPassword(url: string): string { + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1) { + return url; + } + const authorityStart = schemeEnd + 3; + const authorityEnd = (() => { + const rest = url.slice(authorityStart); + const cut = rest.search(/[/?#]/); + return cut === -1 ? url.length : authorityStart + cut; + })(); + + const authority = url.slice(authorityStart, authorityEnd); + // Last `@`: one inside the password itself has to be percent-encoded, so + // whatever follows the final `@` is the host. + const userinfoEnd = authority.lastIndexOf('@'); + if (userinfoEnd === -1) { + return url; + } + const userinfo = authority.slice(0, userinfoEnd); + const separator = userinfo.indexOf(':'); + if (separator === -1) { + return url; + } + + const masked = `${userinfo.slice(0, separator)}:••••••`; + return ( + url.slice(0, authorityStart) + + masked + + authority.slice(userinfoEnd) + + url.slice(authorityEnd) + ); +} + +function maskUrlQueryPasswords(url: string): string { + const queryStart = url.indexOf('?'); + if (queryStart === -1) { + return url; + } + const head = url.slice(0, queryStart + 1); + const rest = url.slice(queryStart + 1); + const hashStart = rest.indexOf('#'); + const query = hashStart === -1 ? rest : rest.slice(0, hashStart); + const hash = hashStart === -1 ? '' : rest.slice(hashStart); + + const masked = query + .split('&') + .map((pair) => { + const separator = pair.indexOf('='); + if (separator === -1) { + return pair; + } + const rawName = pair.slice(0, separator); + let name = rawName; + try { + name = decodeURIComponent(rawName); + } catch { + // A malformed escape stays as written; it cannot be the + // decoded `password` the importer would read either. + } + return name.toLowerCase() === 'password' + ? `${rawName}=••••••` + : pair; + }) + .join('&'); + + return `${head}${masked}${hash}`; +} + +/** + * "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 implements OnInit { + readonly candidateSelected = output(); + + /** + * The pasted text lives in the DIALOG, not here: switching to a prefilled + * form destroys this component (`@switch`), and coming back to compare or + * pick another candidate must not cost the user their paste. The dialog + * feeds the last text back in and listens for edits. + */ + readonly initialText = input(''); + readonly textChanged = output(); + + readonly textControl = new FormControl('', { nonNullable: true }); + + private readonly textValue = toSignal(this.textControl.valueChanges, { + initialValue: '', + }); + + constructor() { + this.textControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((value) => this.textChanged.emit(value)); + } + + ngOnInit(): void { + const initial = this.initialText(); + if (initial) { + this.textControl.setValue(initial); + } + } + + 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': + // get.php links carry the password in their query — the card + // must not show it while every other password here is masked. + // The candidate keeps the untouched URL for prefilling. + push( + 'HOME.URL_UPLOAD.PLAYLIST_URL', + candidate.url && maskUrlPasswords(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 31cf76dd3..2a076185c 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -37,6 +37,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..34a40727c --- /dev/null +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -0,0 +1,1110 @@ +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('reads decorated reseller messages (math-alphabet labels, arrow separators)', () => { + // Real-world shape: labels dressed in Unicode math monospace to + // slip past chat spam filters, ➤ as the separator, ├◉ decoration. + const candidates = detectProviderImportCandidates( + [ + '◉𝙿𝙾𝚁𝚃𝙰𝙻➤ http://tv.example.nl:80/', + '├◉𝚄𝚂𝙴𝚁➤ 6b13aa64d3', + '├◉𝙿𝙰𝚂𝚂➤ 602bb462ab75', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://tv.example.nl', + username: '6b13aa64d3', + password: '602bb462ab75', + }); + }); + + it.each([ + ['arrow', 'USER ➤ carol\nPASS ➤ pa55'], + ['triangle', 'User ► carol\nPass ► pa55'], + ['unicode arrow', 'login → carol\npassword → pa55'], + ['ascii gt', 'USER > carol\nPASS > pa55'], + ['spaced dash', 'User - carol\nPass - pa55'], + ])( + 'accepts the %s label separator', + (_variant, message) => { + const xtream = only( + detectProviderImportCandidates(message), + 'xtream' + ); + expect(xtream).toHaveLength(1); + expect(xtream[0].username).toBe('carol'); + expect(xtream[0].password).toBe('pa55'); + } + ); + + it('folds negative-circled font labels (🅤🅢🅔🅡 / 🅟🅐🅢🅢) and reads the get.php line', () => { + // Verbatim reseller message: negative-circled capitals NFKC does + // not fold, ➤➤ separators, and a "🅜➌🅤 🅛🅘🅢🅣" (M3U LIST) get.php + // line whose query is the authoritative credential source. + const candidates = detectProviderImportCandidates( + [ + '╭● 🅤🅡🅛 ➤➤ http://raztv.online:80', + '├● 🅤🅢🅔🅡 ➤➤ 94U19T3EAQ68DAP', + '├● 🅟🅐🅢🅢 ➤➤ 6uU3DX7OP5', + '╰● 🅜➌🅤 🅛🅘🅢🅣 ➤➤ http://raztv.online:80/get.php?username=94U19T3EAQ68DAP&password=6uU3DX7OP5&type=m3u_plus', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://raztv.online', + username: '94U19T3EAQ68DAP', + password: '6uU3DX7OP5', + }); + }); + + it('folds regional-indicator fancy text but leaves real flag emoji alone', () => { + // 🇺🇸🇪🇷 / 🇵🇦🇸🇸 are runs of 3+ regional indicators — fancy text + // for USER / PASS. 🇹🇷 is a run of exactly two — a real flag that + // must not fold, or the glued "TR" would break the PORT label's + // word boundary. + const candidates = detectProviderImportCandidates( + [ + 'Server: iptv.example.tv', + '🇹🇷PORT: 8080', + '🇺🇸🇪🇷 : carol', + '🇵🇦🇸🇸 : pa55', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://iptv.example.tv:8080', + username: 'carol', + password: 'pa55', + }); + }); + + it('does not read a hyphenated word as a labeled value', () => { + expect( + detectProviderImportCandidates( + 'Our user-friendly setup takes a minute.' + ) + ).toEqual([]); + }); + + it('reads an email-shaped username and ignores stats lines (corpus #1)', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://lordstreams.live:80', + 'User=mszala@comcast.net', + 'Pass=0323910031', + '30602 channels', + 'Validity 27/12/26', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://lordstreams.live', + username: 'mszala@comcast.net', + password: '0323910031', + }); + }); + + it('reads the bare "URL, token, token" handout shape (corpus #2)', () => { + const candidates = detectProviderImportCandidates( + 'http://alltvmx.com\nVictoria89\nVictoria89' + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + confidence: 'low', + serverUrl: 'http://alltvmx.com', + username: 'Victoria89', + password: 'Victoria89', + }); + }); + + it('does not read prose around a lone URL as bare credentials', () => { + expect( + detectProviderImportCandidates( + 'check out http://example.com\nsee the attached guide\nthanks again' + ) + ).toEqual([]); + }); + + it('handles indented labels and numeric credentials (corpus #4)', () => { + const candidates = detectProviderImportCandidates( + 'http://eliplay.onl:8089\nUser: 111\n Pass: 111' + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://eliplay.onl:8089', + username: '111', + password: '111', + }); + }); + + it('prefers the get.php line of a full panel dump (corpus #5)', () => { + const candidates = detectProviderImportCandidates( + [ + '𝙋𝙤𝙧𝙩𝙖𝙡 : http://31.43.191.125', + '• 𝘾𝙧𝙚𝙖𝙩𝙚𝙙 : 09/02/2026', + '• 𝙀𝙭𝙥 : 10/02/2027', + '• 𝙐𝙨𝙚𝙧 : vip6001770656088', + '• 𝙋𝙖𝙨𝙨 : c394e34a0504', + '• 𝘾𝙤𝙣𝙣 : 1', + '• 𝙈𝙖𝙭𝘾𝙤𝙣𝙣 : 1', + '• 𝙋𝙤𝙧𝙩 : 8080', + '• 𝙎𝙩𝙖𝙩𝙪𝙨 : # OK ✅', + '• 𝐌𝟑𝐔 : http://31.43.191.125:8080/get.php?username=vip6001770656088&password=c394e34a0504&type=m3u', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + confidence: 'high', + serverUrl: 'http://31.43.191.125:8080', + username: 'vip6001770656088', + password: 'c394e34a0504', + }); + }); + + it('completes a port-less labeled portal URL with the labeled port', () => { + const candidates = detectProviderImportCandidates( + [ + 'Portal : http://panel.example.io', + 'Port : 8080', + 'User : u1', + 'Pass : p1', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream[0].serverUrl).toBe('http://panel.example.io:8080'); + }); + + it('reads an inline one-line handout with pipe delimiters', () => { + // No space after the pipe: the URL must not swallow the next + // label, or the account silently produces no candidate at all. + const candidates = detectProviderImportCandidates( + 'Server: http://panel.example.org|User: alice|Pass: s3cret' + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://panel.example.org', + username: 'alice', + password: 's3cret', + }); + }); + + it('keeps an explicitly written default port over a labeled one', () => { + // The WHATWG parser blanks `:80`, but the user wrote it. + const candidates = detectProviderImportCandidates( + [ + 'Server: http://primary.example.org:80/player_api.php', + 'Port: 8080', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + // `:80` is http's default, so the shared normalizer collapses it + // away — what matters is that the labeled 8080 never replaced it. + expect(only(candidates, 'xtream')[0].serverUrl).toBe( + 'http://primary.example.org' + ); + }); + + it('keeps distinct accounts whose credentials contain the key separator', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://panel.example.org/get.php?username=a%7Cb&password=c', + 'http://panel.example.org/get.php?username=a&password=b%7Cc', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(2); + expect( + xtream.map((candidate) => [ + candidate.username, + candidate.password, + ]) + ).toEqual([ + ['a|b', 'c'], + ['a', 'b|c'], + ]); + }); + + it('applies a labeled port to an IPv6 endpoint', () => { + // The address is full of colons; none of them is a port. + const candidates = detectProviderImportCandidates( + [ + 'Server: http://[2001:db8::1]/player_api.php', + 'Port: 8080', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://[2001:db8::1]:8080'); + }); + + it('ignores host-shaped query keys inside unrelated links', () => { + // `?url=guide` is a query key, not a label — the real "Server:" + // line below it must still be the one the credentials attach to. + const candidates = detectProviderImportCandidates( + [ + 'Guide: https://reseller.example/setup?url=guide', + 'Server: http://panel.example.org', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://panel.example.org'); + }); + + it('strips sentence punctuation from a labeled server URL', () => { + const candidates = detectProviderImportCandidates( + [ + 'Server: http://panel.example.com!', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://panel.example.com'); + }); + + it('completes a port-less API URL with the separately labeled port', () => { + const candidates = detectProviderImportCandidates( + [ + 'Server: http://panel.example.org/player_api.php', + 'Port: 8080', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://panel.example.org:8080'); + }); + + it('does not let a labeled port override an explicit one', () => { + const candidates = detectProviderImportCandidates( + [ + 'Server: http://panel.example.org:2500/player_api.php', + 'Port: 8080', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + expect(only(candidates, 'xtream')[0].serverUrl).toBe( + 'http://panel.example.org:2500' + ); + }); + + it('strips panel_api.php from the derived server URL', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://panel.example.org/panel_api.php', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://panel.example.org'); + }); + + 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('keeps the closing bracket of a root IPv6 portal URL', () => { + // `]` closes the authority here; stripping it as prose would + // leave an address that cannot be parsed or imported. + const candidates = detectProviderImportCandidates( + ['Portal: http://[2001:db8::1]', 'MAC: 00:1A:79:12:34:56'].join( + '\n' + ) + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0].portalUrl).toBe('http://[2001:db8::1]'); + }); + + 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('reads a dual "DEVICE ID=> 1&2 " line into both device slots and "S N" as serial', () => { + const deviceHex = + 'FCE5A3B00FA5E2B9207E23FC205536314200BEE0FB2FCF0608FB97B6C7337E7F'; + const candidates = detectProviderImportCandidates( + [ + 'http://tv.saartv.cc/stalker_portal/c', + 'MAC=> 00:1A:79:00:E3:C9', + 'EXP=> 2030-01-13', + 'MAX => 10000', + 'ADULT PASS=> 7700', + 'S N=> F2DD20855EE0C', + `DEVICE ID=> 1&2 ${deviceHex}`, + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://tv.saartv.cc/stalker_portal/c', + macAddress: '00:1A:79:00:E3:C9', + serialNumber: 'F2DD20855EE0C', + deviceId1: deviceHex, + deviceId2: deviceHex, + }); + // The parental-control PIN is not the account password. + expect(stalker[0].password).toBeUndefined(); + }); + + it('reads a Turkish-labeled decorated portal message', () => { + const candidates = detectProviderImportCandidates( + [ + '⚙️➤System : MediaHack Pro v7.7 (MAC)', + '🔌➤Portal Type : Stalker / XUI (/c/)', + '✅➤Tarama Zamanı: 2026-03-05 13:28:23', + '🌍➤Portal : http://b1.jinbox.nl:80/c/', + '🆔➤MAC ADRESİ: 00:1A:79:B4:EB:EB', + '📆➤Son Tarih : Süresiz', + '🖥➤Plan : MAG/MAC', + '🔊➤ɢᴏʀᴜɴᴛᴜ: Var 👍', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://b1.jinbox.nl:80/c/', + macAddress: '00:1A:79:B4:EB:EB', + }); + }); + + it('reads a scanner dump with Real/Panel URLs and bold-italic labels (corpus #3)', () => { + const candidates = detectProviderImportCandidates( + [ + '├○ 𝑹𝒆𝒂𝒍 ➤ http://main.light-ott.net:80/c/', + '├○ 𝑷𝒂𝒏𝒆𝒍 ➤ http://main.light-ott.net/c/', + '├○ 𝑴𝒂𝒄 ➤ 00:1A:79:f5:cd:04', + '├○ 𝑷𝒂𝒏𝒆𝒍 𝑻𝒚𝒑𝒆 ➤ portal.php', + '├○ 𝑬𝒙𝒑 ➤ July 26, 2027, 12:41 pm', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + confidence: 'high', + portalUrl: 'http://main.light-ott.net:80/c/', + macAddress: '00:1A:79:F5:CD:04', + }); + }); + + it('produces one candidate per MAC of a multi-account list (corpus #6)', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://mag.tivi-one-iptv.net/c/', + '00:1A:79:61:31:34 13/01/2027', + '00:1A:79:9C:E8:78 31/12/2027 (513 Days)', + '00:1A:79:A0:E3:66 18/08/2027 (378 Days)', + '00:1A:79:AD:85:3E 16/08/2027 (377 Days)', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(4); + expect( + stalker.every( + (candidate) => + candidate.portalUrl === 'http://mag.tivi-one-iptv.net/c/' + ) + ).toBe(true); + expect(stalker.map((candidate) => candidate.macAddress)).toEqual([ + '00:1A:79:61:31:34', + '00:1A:79:9C:E8:78', + '00:1A:79:A0:E3:66', + '00:1A:79:AD:85:3E', + ]); + }); + + it('reads separator-less SN and a decorated ¹💥² dual device ID (corpus #8)', () => { + const deviceHex = + 'AD8D35940A3FA14EA07451780224114F276D0F3BB4427E5351436F60ED6BF0BA'; + const candidates = detectProviderImportCandidates( + [ + 'http://tv.saartv.cc/stalker_portal/c/', + 'Mac 00:1a:79:01:07:06', + '𝐄𝐗𝐏 𝐃𝐚𝐭𝐞 2026-08-07', + '🔥 🆅🅿️🅽 Toronto', + '𝐒𝐍 38415545307A3', + `𝐃𝐞𝐯𝐢𝐜𝐞𝐈𝐃 ¹💥² ${deviceHex}`, + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://tv.saartv.cc/stalker_portal/c/', + macAddress: '00:1A:79:01:07:06', + serialNumber: '38415545307A3', + deviceId1: deviceHex, + deviceId2: deviceHex, + }); + }); + + it('ignores scanner footers and status prose around a portal (corpus #9)', () => { + const candidates = detectProviderImportCandidates( + [ + '├○ 𝑺𝒄𝒂𝒏𝒏𝒆𝒅 𝑩𝒚 ➤ ★☆★ 𝓜𝓲𝓱𝓪𝓳𝓵𝓸 𝓘𝓿𝓲𝓬 ★☆★', + '├○ 𝑺𝒄𝒂𝒏 𝑫𝒂𝒕𝒆 / 𝑻𝒊𝒎𝒆 ➤ 11.08.2026. • 11:38:54', + '├○ 𝑹𝒆𝒂𝒍 ➤ http://kamariato.com:80', + '├○ 𝑷𝒂𝒏𝒆𝒍 ➤ http://kamariato.com:80/c/', + '├○ 𝑴𝒂𝒄 ➤ 00:1A:79:f1:6a:9f', + '├○ 𝑷𝒂𝒏𝒆𝒍 𝑻𝒚𝒑𝒆 ➤ portal.php', + '├○ 𝑬𝒙𝒑 ➤ July 12, 2026, 12:00 am -30 Days', + '├○ 𝑽𝒑𝒏 ➤ 𝑁𝑜 𝐶𝑙𝑖𝑒𝑛𝑡 𝐼𝑃 𝐴𝑑𝑑𝑟𝑒𝑠𝑠', + '├○ 𝑰𝒎𝒂𝒈𝒆 ➤ 𝐼𝑛𝑣𝑎𝑙𝑖𝑑 𝑂𝑝𝑝𝑠', + '└──➢ 𝑴𝑨𝑪 𝑷𝑹𝑶 𝑨𝑻𝑳𝑨𝑺', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://kamariato.com:80/c/', + macAddress: '00:1A:79:F1:6A:9F', + }); + expect(only(candidates, 'xtream')).toHaveLength(0); + }); + + it('reads a [+]-prefixed dump with serial, device ID and signature (corpus #11)', () => { + const candidates = detectProviderImportCandidates( + [ + '[+] Panel: http://mag.jee-ott.xyz:80/c', + '[+] Real: mag.jee-ott.xyz', + '[+] Mac: 00:1B:79:48:3A:90', + '[+] Exp.: February 27, 2027, 7:33 pm', + '[+] ᴍᴀᴄ ꜱᴛᴀᴛᴜꜱ: ᴍᴀᴄ ᴏꜰꜰ🥺', + '[+] ᴍ3ᴜ ꜱᴛᴀᴛᴜꜱ: ᴏꜰꜰʟɪɴᴇ😔', + '[+] ᴄʜᴀɴɴᴇʟCategories: 684', + '[+] m3u_Url: ', + '', + '[+] Serial: 862E1CDB82415', + '[+] Device ID: FA9CBD1857D1D8A57BF1A03542B928CB90779C7CE2673CAC85A5FD4F8B7756E5', + '[+] Signature: A48D3EDCCC09B580CD19A07C8B0A9C44F1D00377CEE9913EE2ADCD858776FD67', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://mag.jee-ott.xyz:80/c', + macAddress: '00:1B:79:48:3A:90', + serialNumber: '862E1CDB82415', + deviceId1: + 'FA9CBD1857D1D8A57BF1A03542B928CB90779C7CE2673CAC85A5FD4F8B7756E5', + signature1: + 'A48D3EDCCC09B580CD19A07C8B0A9C44F1D00377CEE9913EE2ADCD858776FD67', + }); + expect(stalker[0].deviceId2).toBeUndefined(); + }); + + it('does not cross-pair global identity labels onto multiple MACs', () => { + // First-match-wins labels cannot say WHOSE serial/device ID they + // are once a second MAC appears — and a device ID submitted with + // the wrong MAC gets pinned by the portal permanently. Multi-MAC + // candidates must therefore carry portal + MAC only. + const candidates = detectProviderImportCandidates( + [ + 'Portal: http://multi.example.com/c/', + 'MAC: 00:1A:79:11:11:11', + `Device ID: ${'a'.repeat(64)}`, + 'SN: AAAA1111BBBB2', + 'MAC: 00:1A:79:22:22:22', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBe( + 'http://multi.example.com/c/' + ); + expect(candidate.deviceId1).toBeUndefined(); + expect(candidate.serialNumber).toBeUndefined(); + } + }); + + it('omits the portal when several MACs meet portals on different hosts', () => { + // Two panels + two MACs in one paste: no deterministic owner for + // either portal, so the candidates carry the MAC alone instead of + // prefilling half of them with the wrong panel. + const candidates = detectProviderImportCandidates( + [ + 'http://panel-a.example.com/c/', + 'MAC: 00:1A:79:AA:AA:AA', + 'http://panel-b.example.com/c/', + 'MAC: 00:1A:79:BB:BB:BB', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBeUndefined(); + expect(candidate.confidence).toBe('low'); + } + }); + + it('completes a port-less shaped portal with the separately labeled port', () => { + const candidates = detectProviderImportCandidates( + [ + 'Portal: http://stb.example.com/c/', + 'Port: 8080', + 'MAC: 00:1A:79:12:34:56', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0].portalUrl).toBe('http://stb.example.com:8080/c/'); + }); + + it('treats tenant installs under different base paths as different panels', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://panel.example.com/a/stalker_portal/c/', + 'MAC: 00:1A:79:AA:AA:AA', + 'http://panel.example.com/b/stalker_portal/c/', + 'MAC: 00:1A:79:BB:BB:BB', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBeUndefined(); + } + }); + + it('applies the labeled port before judging portal ambiguity', () => { + // Both URLs are the same panel once "Port: 8080" completes the + // port-less one — the MAC list must keep the shared portal. + const candidates = detectProviderImportCandidates( + [ + 'http://panel.example.com:8080/c/', + 'http://panel.example.com/c/', + 'Port: 8080', + 'MAC: 00:1A:79:AA:AA:AA', + 'MAC: 00:1A:79:BB:BB:BB', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBe( + 'http://panel.example.com:8080/c/' + ); + } + }); + + it('keeps alternate endpoints of one install unambiguous for a MAC list', () => { + // /c/ and portal.php are two doors into the same installation — + // a MAC list next to both must still get the shared portal. + const candidates = detectProviderImportCandidates( + [ + 'http://panel.example.com/c/', + 'http://panel.example.com/portal.php', + 'MAC: 00:1A:79:AA:AA:AA', + 'MAC: 00:1A:79:BB:BB:BB', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBe('http://panel.example.com/c/'); + } + }); + + it('treats same-host portals on different ports as different panels', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://panel.example.com:8080/c/', + 'MAC: 00:1A:79:AA:AA:AA', + 'http://panel.example.com:9090/c/', + 'MAC: 00:1A:79:BB:BB:BB', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBeUndefined(); + } + }); + + it('treats several MACs with generic URLs on different hosts as ambiguous too', () => { + // Without portal-shaped URLs the picker falls back to a generic + // URL — the ambiguity guard must cover that pool as well, or two + // root-URL panels would still cross-pair through the fallback. + const candidates = detectProviderImportCandidates( + [ + 'http://panel-a.example.com', + 'MAC: 00:1A:79:AA:AA:AA', + 'http://panel-b.example.com', + 'MAC: 00:1A:79:BB:BB:BB', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBeUndefined(); + } + }); + + it('surfaces every MAC of a long multi-account list', () => { + const macs = Array.from( + { length: 20 }, + (_, index) => + `00:1A:79:${String(index).padStart(2, '0')}:11:22` + ); + const candidates = detectProviderImportCandidates( + ['http://long.example.com/c/', ...macs].join('\n') + ); + + expect(only(candidates, 'stalker')).toHaveLength(20); + }); + + 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('does not read credentials out of URLs beyond the extraction cap', () => { + // URL #17 is past the 16-URL cap and never becomes a candidate; + // its query credentials must not leak into the label matchers and + // attach to the early credential-less panel. + const filler = Array.from( + { length: 15 }, + (_, index) => `https://lists.example.com/list-${index}.m3u` + ); + const candidates = detectProviderImportCandidates( + [ + 'http://early.example.org/player_api.php', + ...filler, + 'http://late.example.org/x?username=wrong&password=wrongpass', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://early.example.org'); + expect(xtream[0].username).toBeUndefined(); + expect(xtream[0].password).toBeUndefined(); + }); + + it('surfaces every link of a long list and caps only pathological pastes', () => { + const twelve = Array.from( + { length: 12 }, + (_, index) => `https://lists.example.com/list-${index}.m3u` + ).join('\n'); + expect(detectProviderImportCandidates(twelve)).toHaveLength(12); + + const twenty = Array.from( + { length: 20 }, + (_, index) => `https://lists.example.com/list-${index}.m3u` + ).join('\n'); + expect(detectProviderImportCandidates(twenty)).toHaveLength(16); + }); + }); +}); 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..be6466c85 --- /dev/null +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -0,0 +1,469 @@ +import { + DetectedUrl, + extractLabeledFields, + extractMacAddresses, + extractUrls, + foldDecorativeAlphabets, + 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 NFKC-normalized 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. NFKC + * is applied once up front because reseller messages dress labels in + * Unicode math alphabets (`𝚄𝚂𝙴𝚁➤`, `𝙿𝙰𝚂𝚂➤`) to slip past chat spam + * filters; the folding turns them back into the ASCII they denote, and + * real credentials/URLs are ASCII already, so values pass through it + * unchanged. The one deliberate exception is `labeledHostUrl`, which + * completes a scheme-less labeled host with `http://` and a separately + * labeled port — an assembled value, landing in an editable form field. + * 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; +// DOM-safety guard against pathological pastes, not a business limit: sized +// above the scanners' combined caps (64 MACs + 16 URLs), so every candidate +// a real multi-account handout produces survives the final slice. +const MAX_CANDIDATES = 96; + +/** + * 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 — the ORIGINAL bytes, since + // a playlist body is content, not decoration to be folded. + if (EXTM3U_HEADER_PATTERN.test(raw)) { + return [{ kind: 'm3u-text', confidence: 'high', text: raw }]; + } + + // All scanning runs on one consistently normalized string so label + // positions, URL spans and masking agree with each other. NFKC folds the + // math alphabets; the extra pass folds the squared/negative-circled/ + // regional-indicator "font" letters and circled digits NFKC leaves alone. + // Both only touch decorative glyphs — ASCII credentials and URLs, hence + // every emitted value, pass through unchanged. + const normalized = foldDecorativeAlphabets(raw.normalize('NFKC')); + const urls = extractUrls(normalized); + const labeled = extractLabeledFields(normalized); + const macs = extractMacAddresses(normalized, 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. + // + // Identity fields attach ONLY when the message names exactly one account: + // labeled extraction is first-match-wins over the whole text, so with + // several MACs there is no reliable way to tell whose serial/device ID a + // label belongs to — and a device ID submitted with the wrong MAC is + // pinned by the portal permanently. Multi-MAC candidates therefore carry + // portal + MAC only, and the user supplies identity per account. + // Several MACs meeting portal candidates on DIFFERENT hosts is two + // accounts from two panels in one paste — there is no deterministic way + // to say which MAC belongs to which portal, so those candidates carry the + // MAC alone instead of prefilling half of them with the wrong panel. + // The ambiguity is judged over the POOL `pickStalkerPortalUrl` would + // actually draw from: portal-shaped URLs when any exist, otherwise the + // generic-URL fallback — a guard on the shaped pool alone would let two + // root-URL panels slip through the fallback door. A single labeled + // "Portal:" line, same-host Real/Panel pairs, and one portal shared by a + // whole MAC list stay unambiguous. + const shapedPortals = urls.filter((url) => url.role === 'stalker'); + const portalPool = + shapedPortals.length > 0 + ? shapedPortals + : labeledHostUrl(labeled) !== undefined + ? [] + : urls.filter((url) => url.role === 'generic'); + const portalAmbiguous = + macs.length > 1 && + new Set( + portalPool.map((url) => portalInstallationKey(url, labeled.port)) + ).size > 1; + const portal = portalAmbiguous + ? null + : pickStalkerPortalUrl(urls, labeled); + const identity: LabeledFields = macs.length === 1 ? labeled : {}; + for (const macAddress of macs) { + candidates.push( + compact({ + kind: 'stalker', + confidence: portal?.confidence ?? 'low', + portalUrl: portal?.url, + macAddress, + serialNumber: identity.serialNumber, + deviceId1: identity.deviceId1, + deviceId2: identity.deviceId2, + signature1: identity.signature1, + signature2: identity.signature2, + username: identity.username, + password: identity.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); + // A separately labeled "Port:" completes a port-less API URL, same + // as the labeled-host fallback already does. + const completedRaw = completeWithLabeledPort(url, labeled.port); + candidates.push( + compact({ + kind: 'xtream', + confidence: username && password ? 'high' : 'medium', + serverUrl: safeXtreamServerUrl(completedRaw), + 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: completedRaw, + 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), + }) + ); + } + + // ── Bare handout: exactly "URL, token, token" and nothing else readable. + // "http://host\nVictoria89\nVictoria89" carries no labels at all — the + // shape IS the message (server, then username, then password, in reading + // order). Deliberately the last resort and ranked low: it only fires when + // no label, MAC or API URL produced anything, the single URL is generic, + // and the message contains nothing beyond those three lines, so prose + // cannot be misread as credentials. + if ( + macs.length === 0 && + !labeled.username && + !labeled.password && + candidates.length === 0 && + urls.length === 1 && + urls[0].role === 'generic' + ) { + const tokens = bareCredentialTokens(normalized, urls[0]); + if (tokens) { + candidates.push( + compact({ + kind: 'xtream', + confidence: 'low', + serverUrl: safeXtreamServerUrl(urls[0].raw), + username: tokens[0], + password: tokens[1], + suggestedTitle: urls[0].parsed.hostname, + }) + ); + } + } + + // ── 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); +} + +/** + * Ambiguity key for a portal candidate: origin plus the installation's base + * path. Origin (not hostname) separates panels on different ports while URL + * normalization drops default ports, so the Real/Panel `:80`-vs-bare pairs + * of scanner dumps compare equal. The base path separates tenant installs + * sharing one origin (`/a/stalker_portal/c/` vs `/b/…`), while the known + * endpoint suffixes of ONE install (`/c/`, `portal.php`, `server/load.php`) + * are stripped so its alternate endpoints reduce to the same key. The key is + * derived AFTER labeled-port completion, so `http://panel:8080/c/` next to + * `http://panel/c/` plus a `Port: 8080` line reads as one panel, exactly as + * the picker would complete it. + */ +function portalInstallationKey( + url: DetectedUrl, + labeledPort: string | undefined +): string { + let parsed = url.parsed; + const completed = completeWithLabeledPort(url, labeledPort); + if (completed !== url.raw) { + try { + parsed = new URL(completed); + } catch { + parsed = url.parsed; + } + } + const base = parsed.pathname + .replace(/\/(?:stalker_portal\/)?c\/?$/i, '') + .replace(/\/stalker_portal\/?$/i, '') + .replace(/\/portal\.php$/i, '') + .replace(/\/(?:stalker_portal\/)?server\/load\.php$/i, '') + .replace(/\/+$/, ''); + return `${parsed.origin}${base}`; +} + +/** + * Completes a found URL with a separately labeled port — "Portal: http://x/…" + * plus "Port: 8080" on its own line is a real handout shape (corpus #5). + * Applied only when the URL itself states no explicit port, so a written + * `:80` is never overridden by the label. + */ +/** + * Whether the URL states a port itself. Read off the RAW authority rather + * than `parsed.port`, which the WHATWG parser blanks for a protocol's default + * (`http://host:80` reports no port) — a written `:80` is still the user's + * explicit choice and must not be overwritten by a labeled one. An IPv6 + * literal's own colons are skipped by cutting the bracketed address first. + */ +function hasExplicitPort(raw: string): boolean { + const afterScheme = raw.slice(raw.indexOf('://') + 3); + const authority = afterScheme.split(/[/?#]/, 1)[0]; + const afterAddress = authority.startsWith('[') + ? authority.slice(authority.indexOf(']') + 1) + : authority; + return /:\d+$/.test(afterAddress); +} + +function completeWithLabeledPort( + url: DetectedUrl, + port: string | undefined +): string { + if (!port || hasExplicitPort(url.raw)) { + return url.raw; + } + const { protocol, hostname, pathname, search } = url.parsed; + return `${protocol}//${hostname}:${port}${pathname}${search}`; +} + +function pickStalkerPortalUrl( + urls: DetectedUrl[], + labeled: LabeledFields +): { url: string; confidence: ProviderImportConfidence } | null { + const shaped = urls.find((url) => url.role === 'stalker'); + if (shaped) { + return { + url: completeWithLabeledPort(shaped, labeled.port), + 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: completeWithLabeledPort(generic, labeled.port), + confidence: 'medium', + }; + } + return null; +} + +/** + * For the bare "URL, token, token" shape: returns the two credential-shaped + * tokens when the message consists of exactly three non-empty lines — the + * URL and two single tokens — and nothing else. + */ +function bareCredentialTokens( + text: string, + url: DetectedUrl +): [string, string] | null { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (lines.length !== 3) { + return null; + } + const tokens = lines.filter((line) => !line.includes(url.raw)); + if (tokens.length !== 2) { + return null; + } + const credentialShaped = /^[A-Za-z0-9._@-]{3,64}$/; + return credentialShaped.test(tokens[0]) && credentialShaped.test(tokens[1]) + ? [tokens[0], tokens[1]] + : 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) => { + // JSON rather than a joined string: a credential may legitimately + // contain the separator (URL-encoded in the query), and two different + // accounts must never collapse into one key because of it. Same + // server + username with a DIFFERENT password is a distinct account + // (e.g. a rotation message), so the password is part of the key. + const key = JSON.stringify([ + candidate.kind, + candidate.url ?? '', + candidate.serverUrl ?? '', + candidate.username ?? '', + candidate.password ?? '', + candidate.portalUrl ?? '', + candidate.macAddress ?? '', + ]); + 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..e09943b2f --- /dev/null +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -0,0 +1,556 @@ +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; +} + +// `|` ends a URL: it is not a legal URL character, but it IS the inline field +// delimiter of one-line handouts ("Server: http://host|User: alice"). Letting +// the span run through it would mask the very next label and lose that +// account entirely. +const URL_PATTERN = /https?:\/\/[^\s<>"'`|]+/gi; +const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/; + +/** + * Strips sentence punctuation a URL picked up from prose ("see http://x/y.") + * — unless doing so breaks the address itself. `]` is both a closing quote in + * prose and the structural end of an IPv6 authority (`http://[2001:db8::1]`), + * and only parsing can tell the two apart: the stripped form wins whenever it + * still parses, otherwise the original stands. Values with no scheme parse + * either way, so they keep the plain stripped form. + */ +function stripTrailingProsePunctuation(value: string): string { + const stripped = value.replace(TRAILING_PUNCTUATION, ''); + if (stripped === value || parsesAsUrl(stripped) || !parsesAsUrl(value)) { + return stripped; + } + return value; +} + +function parsesAsUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } +} +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; + +// Both caps are DOM-safety guards against pathological pastes (a log file +// full of MAC-shaped hex would otherwise render thousands of cards), sized +// far beyond any real handout — reseller and scanner dumps top out around a +// couple dozen accounts. The assembler's candidate cap leaves headroom for +// both scanners together, so neither truncates real input before assembly. +const MAX_URLS = 16; +const MAX_MACS = 64; + +interface FieldMatcher { + field: keyof LabeledFields; + pattern: RegExp; + validate?: (value: string) => boolean; +} + +/** A label must not continue a word ("sublogin" is not a login label). */ +const LABEL_LOOKBEHIND = String.raw`(?` any run of arrow/geometric/dingbat glyphs (U+2190–21FF, + * U+25A0–25FF, U+2600–27BF, U+2B00–2BFF) counts. A dash counts only when it + * stands alone between spaces: "PASS - abc" is a separator, "user-friendly" + * is prose. Some symbol IS still required — a bare "login example" in prose + * must keep failing to match. + */ +const LABEL_SEPARATOR = String.raw`(?:\s*(?:[:=>]|[←-⇿■-◿☀-➿⬀-⯿])+\s*|\s+[-–—]+\s+)`; + +const DEFAULT_VALUE = String.raw`[^\s,;|]+`; + +function labeledPattern(labels: string, value = DEFAULT_VALUE): RegExp { + return new RegExp( + `${LABEL_LOOKBEHIND}(?:${labels})${LABEL_SEPARATOR}(${value})`, + 'iu' + ); +} + +const isHexIdentity = (value: string) => HEX_IDENTITY_PATTERN.test(value); + +/** + * Folds decorative Unicode "font" alphabets that NFKC deliberately leaves + * alone — squared (🄰), negative circled (🅐), negative squared (🅰) and + * regional-indicator (🇦) capitals, plus the dingbat circled digits (➀ ❶ ➌) + * and ⓿ — back to the ASCII they depict. NFKC already handles the math + * alphabets (`𝚄𝚂𝙴𝚁`) and plain circled letters (Ⓐ); these blocks carry no + * compatibility decomposition because Unicode treats them as symbols, but in + * a reseller message `🅟🅐🅢🅢➤` is just PASS in a costume. Callers run this + * AFTER `normalize('NFKC')`, and the label matchers below then see plain + * ASCII. + */ +export function foldDecorativeAlphabets(text: string): string { + const chars = Array.from(text); + let result = ''; + for (let index = 0; index < chars.length; ) { + const code = chars[index].codePointAt(0) as number; + if (isRegionalIndicator(code)) { + // Regional indicators are ambiguous: a run of EXACTLY two is a + // real flag emoji (🇳🇱) that must survive untouched — folding it + // would glue "NL" onto whatever follows and break an adjacent + // label's word boundary. Fancy-text words (🇺🇸🇪🇷, 🇺🇷🇱) are runs + // of three or more, so only those fold to letters. + let end = index; + while ( + end < chars.length && + isRegionalIndicator(chars[end].codePointAt(0) as number) + ) { + end += 1; + } + const run = chars.slice(index, end); + result += + run.length >= 3 + ? run + .map((char) => + String.fromCharCode( + 0x41 + + ((char.codePointAt(0) as number) - + REGIONAL_INDICATOR_A) + ) + ) + .join('') + : run.join(''); + index = end; + continue; + } + result += foldDecorativeCodePoint(code) ?? chars[index]; + index += 1; + } + return result; +} + +const REGIONAL_INDICATOR_A = 0x1f1e6; +const REGIONAL_INDICATOR_Z = 0x1f1ff; + +function isRegionalIndicator(code: number): boolean { + return code >= REGIONAL_INDICATOR_A && code <= REGIONAL_INDICATOR_Z; +} + +const DECORATIVE_LATIN_RANGES: Array<[number, number]> = [ + [0x1f130, 0x1f149], // 🄰 squared A–Z + [0x1f150, 0x1f169], // 🅐 negative circled A–Z + [0x1f170, 0x1f189], // 🅰 negative squared A–Z +]; + +const DECORATIVE_DIGIT_RANGES: Array<[number, number]> = [ + [0x2776, 0x277e], // ❶ negative circled 1–9 + [0x2780, 0x2788], // ➀ circled sans-serif 1–9 + [0x278a, 0x2792], // ➊ negative circled sans-serif 1–9 +]; + +function foldDecorativeCodePoint(code: number): string | null { + for (const [start, end] of DECORATIVE_LATIN_RANGES) { + if (code >= start && code <= end) { + return String.fromCharCode(0x41 + (code - start)); + } + } + for (const [start, end] of DECORATIVE_DIGIT_RANGES) { + if (code >= start && code <= end) { + return String.fromCharCode(0x31 + (code - start)); + } + } + return code === 0x24ff ? '0' : null; // ⓿ +} + +// 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). +// +// The label vocabulary is deliberately small (English + Russian) until a +// corpus of real provider messages justifies growing it. Callers hand this +// table NFKC-normalized text, which is what folds the decorative +// math-alphabet labels (`𝚄𝚂𝙴𝚁`, `𝙿𝙰𝚂𝚂`) into the plain ASCII matched here. +const FIELD_MATCHERS: FieldMatcher[] = [ + { + field: 'deviceId2', + pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*2`), + validate: isHexIdentity, + }, + { + field: 'deviceId1', + pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*1?`), + validate: isHexIdentity, + }, + { + field: 'signature2', + pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*2`), + validate: isHexIdentity, + }, + { + field: 'signature1', + pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*1?`), + validate: isHexIdentity, + }, + { + field: 'serialNumber', + // `s[\s._/-]?n` covers SN, S/N, S.N and the spaced "S N" handouts use. + pattern: labeledPattern( + String.raw`serial[\s_-]*(?:number|no)?|s[\s._/-]?n|серийный(?:[\s_-]*номер)?` + ), + validate: (value) => value.length <= 64, + }, + { + field: 'serialNumber', + // Separator-less handout ("SN 38415545307A3"): allowed only because + // the hex-shaped value cannot be mistaken for prose. + pattern: new RegExp( + LABEL_LOOKBEHIND + + String.raw`(?:serial|s[\s._/-]?n)\s+([0-9A-Fa-f]{8,64})\b`, + 'iu' + ), + }, + { + field: 'macAddress', + // Turkish panels label it "MAC ADRESİ" — the trailing İ/ı/i is listed + // in a class because JS case-insensitive matching does not fold the + // Turkish dotted/dotless I to ASCII `i`. + pattern: labeledPattern( + String.raw`mac(?:[\s_-]*(?:address|adres[iİıI]?))?|мак(?:[\s_-]*адрес)?`, + String.raw`[0-9A-Fa-f:. -]{12,23}` + ), + validate: (value) => normalizeStalkerMacAddress(value) !== null, + }, + { + field: 'username', + pattern: labeledPattern( + String.raw`user[\s_-]*name|login|user|логин|пользователь` + ), + }, + { + field: 'password', + // "ADULT PASS" / "PARENTAL PASS" is the parental-control PIN, not the + // account password — the variable-length lookbehinds skip those + // occurrences and let the engine find an unqualified PASS elsewhere. + pattern: new RegExp( + `${LABEL_LOOKBEHIND}(?(); + const matcher = freshMatcher(URL_PATTERN); + let match: RegExpExecArray | null; + while ( + detected.length < MAX_URLS && + (match = matcher.exec(text)) !== null + ) { + const raw = stripTrailingProsePunctuation(match[0]); + 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) }); + } + 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'; +} + +/** + * The "both device IDs" marker: 1 and 2 joined by any symbol run ("1&2", + * "1/2", "1💥2" — superscript ¹² are already folded to 1/2 by NFKC) or by + * plain whitespace. Bare "12" stays a number, not a marker. + */ +const DUAL_MARKER = String.raw`1(?:\s*[^\p{L}\p{N}\s]+\s*|\s+)2`; + +/** + * "DEVICE ID=> 1&2 " — the marker may sit before or after the + * separator, and the separator itself may be plain whitespace ("DeviceID + * 1💥2 "), which is safe here only because the 16–64-hex value shape + * cannot be prose. + */ +const DUAL_DEVICE_ID_PATTERN = new RegExp( + LABEL_LOOKBEHIND + + String.raw`device[\s_-]*ids?` + + `(?:[\\s_-]*${DUAL_MARKER}(?:${LABEL_SEPARATOR}|\\s+)|(?:${LABEL_SEPARATOR}|\\s+)${DUAL_MARKER}\\s+)` + + String.raw`([0-9A-Fa-f]{16,64})\b`, + 'iu' +); + +export function extractLabeledFields(text: string): 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); + const masked = maskSpans(text, spans); + const fields: LabeledFields = {}; + // "DEVICE ID=> 1&2 " (or "DEVICE ID 1&2: ") hands one value to + // both slots; the per-field matchers below cannot express that, and their + // hex validation would otherwise discard the line entirely. + const dual = DUAL_DEVICE_ID_PATTERN.exec(masked); + if (dual) { + fields.deviceId1 = dual[1]; + fields.deviceId2 = dual[1]; + } + for (const { field, pattern, validate } of FIELD_MATCHERS) { + if (fields[field] !== undefined) { + continue; + } + // The host label is the one matcher that reads the RAW text, because + // it legitimately points at a URL ("Portal: http://…"). That means it + // also meets host-shaped query keys inside unrelated links + // (`…/setup?url=guide`), so it keeps scanning past those rather than + // giving up — a real `Server:` line further down must still win. + const match = + field === 'host' + ? firstMatchOutsideSpans(pattern, text, spans) + : pattern.exec(masked); + if (!match) { + 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 pattern of [SEPARATED_MAC_PATTERN, BARE_INFOMIR_MAC_PATTERN]) { + const matcher = freshMatcher(pattern); + let match: RegExpExecArray | null; + while ( + macs.length < MAX_MACS && + (match = matcher.exec(text)) !== null + ) { + push(normalizeStalkerMacAddress(match[0])); + } + } + return 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 { + // Same sentence-punctuation cleanup the URL scanner applies: a labeled + // value takes precedence over the scanned one, so leaving `Server: + // http://host!` uncleaned here would hand the forms a hostname DNS can + // never resolve while the sanitized scanned URL sat right beside it. + const host = labeled.host + ? stripTrailingProsePunctuation(labeled.host) + : undefined; + if (!host) { + return undefined; + } + if (/^https?:\/\//i.test(host)) { + // Panels often hand out "Portal: http://host" and "Port: 8080" as two + // separate lines — a separately labeled port completes a port-less URL. + if (labeled.port) { + try { + const url = new URL(host); + if (!url.port) { + url.port = labeled.port; + return url.toString(); + } + } catch { + return host; + } + } + return host; + } + if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) { + return undefined; + } + const portSuffix = + host.includes(':') || !labeled.port ? '' : `:${labeled.port}`; + return `http://${host}${portSuffix}`; +} + +/** + * Spans of EVERY URL-shaped run in the text — deliberately not derived from + * the capped `extractUrls` result. A URL past that cap still carries a query + * that is label-shaped (`?username=…&password=…`), and an unmasked one would + * feed the label matchers credentials belonging to a source that never became + * a candidate. Spans are cheap index pairs, so the cap that bounds candidate + * assembly does not apply here. + */ +function urlSpans(text: string): Array<[number, number]> { + const spans: Array<[number, number]> = []; + const matcher = freshMatcher(URL_PATTERN); + let match: RegExpExecArray | null; + while ((match = matcher.exec(text)) !== null) { + spans.push([match.index, match.index + match[0].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(''); +} + +/** + * First match of `pattern` whose label does not start inside a URL span. + * + * Both sequences are produced left to right and the spans never overlap, so + * a single forward cursor is enough — rescanning the span list per match + * would be quadratic on a paste full of host-shaped query keys, and this + * runs synchronously on every keystroke. + */ +function firstMatchOutsideSpans( + pattern: RegExp, + text: string, + spans: Array<[number, number]> +): RegExpExecArray | null { + const matcher = freshMatcher( + pattern.flags.includes('g') + ? pattern + : new RegExp(pattern.source, `${pattern.flags}g`) + ); + let cursor = 0; + let match: RegExpExecArray | null; + while ((match = matcher.exec(text)) !== null) { + while (cursor < spans.length && spans[cursor][1] <= match.index) { + cursor += 1; + } + const span = spans[cursor]; + if (!span || !(match.index > span[0] && match.index < span[1])) { + return match; + } + if (match[0] === '') { + matcher.lastIndex += 1; + } + } + return null; +} + +function stripWrapping(value: string): string { + const trimmed = value.trim(); + const stripped = trimmed.replace(WRAPPING_CHARS, '').trim(); + // Same hazard as the prose-punctuation cleanup: `]` closes both a bracket + // quote and an IPv6 authority, so a strip that breaks the address loses. + if (stripped === trimmed || parsesAsUrl(stripped) || !parsesAsUrl(trimmed)) { + return stripped; + } + return trimmed; +} diff --git a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts index d158b0a8a..7843e640a 100644 --- a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts +++ b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts @@ -14,7 +14,10 @@ export interface XtreamCredentialsFromUrl { username: string; } -const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api)\.php$/i; +// panel_api.php is the legacy admin-flavored endpoint some panels hand out; +// it identifies an Xtream server just like the other two and must be +// stripped the same way, or the transport would append /player_api.php to it. +const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i; // Some Xtream panels challenge generic Node HTTP clients while allowing // established IPTV players. Keep API, probe, and download requests aligned. 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';