From bee7df1e024f7a745c4f8164d784ccf21dd9333f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:19:54 +0200 Subject: [PATCH] feat(playlist): auto-detect import method that parses pasted provider messages (#1445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Auto-detect" method to the Add playlist dialog: paste the message a provider sent — links, Xtream credentials, a MAC address with device identity — and a deterministic parser recognizes the source(s) and prefills the matching import form. - detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC addresses and labeled fields, classifies each finding as Xtream, Stalker or an M3U link/body, and returns ranked candidates. Pure and synchronous. - Built against a corpus of 19 real reseller handouts kept verbatim in the spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a guard so a parental PIN is never read as the account password. - Detection only proposes: the target form's own validation and behavioral probes remain the sole path into the store, and no pasted text leaves the app. Passwords are masked on candidate cards, including query and HTTP Basic userinfo forms. - Covered by parser, component and dialog unit tests plus two web E2E specs for the paste → pick → prefilled form workflow; i18n for all 19 languages. --- .changes/playlist-auto-detect-import.md | 10 + CLAUDE.md | 2 +- apps/web-e2e/src/basic.e2e.ts | 79 ++ apps/web/src/assets/i18n/ar.json | 17 +- apps/web/src/assets/i18n/ary.json | 17 +- apps/web/src/assets/i18n/by.json | 17 +- apps/web/src/assets/i18n/de.json | 17 +- apps/web/src/assets/i18n/el.json | 17 +- apps/web/src/assets/i18n/en.json | 17 +- apps/web/src/assets/i18n/es.json | 17 +- apps/web/src/assets/i18n/fr.json | 17 +- apps/web/src/assets/i18n/hu.json | 17 +- apps/web/src/assets/i18n/it.json | 17 +- apps/web/src/assets/i18n/ja.json | 17 +- apps/web/src/assets/i18n/ko.json | 17 +- apps/web/src/assets/i18n/nl.json | 17 +- apps/web/src/assets/i18n/pl.json | 17 +- apps/web/src/assets/i18n/pt.json | 17 +- apps/web/src/assets/i18n/ru.json | 17 +- apps/web/src/assets/i18n/tr.json | 17 +- apps/web/src/assets/i18n/zh.json | 17 +- apps/web/src/assets/i18n/zhtw.json | 17 +- .../add-playlist-dialog.component.html | 7 + .../add-playlist-dialog.component.scss | 7 +- .../add-playlist-dialog.component.spec.ts | 185 ++- .../add-playlist-dialog.component.ts | 126 +- .../auto-import/auto-import.component.html | 64 + .../auto-import/auto-import.component.scss | 102 ++ .../auto-import/auto-import.component.spec.ts | 147 +++ .../lib/auto-import/auto-import.component.ts | 269 ++++ .../lib/add-playlist-menu/playlist-type.ts | 8 +- .../util/src/lib/portal-shell-actions.ts | 8 +- libs/shared/interfaces/src/index.ts | 1 + .../provider-import-detection.util.spec.ts | 1110 +++++++++++++++++ .../src/lib/provider-import-detection.util.ts | 469 +++++++ .../src/lib/provider-import-scan.util.ts | 556 +++++++++ .../interfaces/src/lib/xtream-portal.utils.ts | 5 +- .../util/src/lib/workspace-shell-actions.ts | 3 +- 38 files changed, 3432 insertions(+), 49 deletions(-) create mode 100644 .changes/playlist-auto-detect-import.md create mode 100644 libs/playlist/import/feature/src/lib/auto-import/auto-import.component.html create mode 100644 libs/playlist/import/feature/src/lib/auto-import/auto-import.component.scss create mode 100644 libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts create mode 100644 libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts create mode 100644 libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/provider-import-detection.util.ts create mode 100644 libs/shared/interfaces/src/lib/provider-import-scan.util.ts 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';