mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
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.
This commit is contained in:
1 parent
03719ba51d
commit
bee7df1e02
38 files changed
+3432
-49
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
|
||||
@@ -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": "الإعدادات",
|
||||
|
||||
@@ -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": "الإعدادات",
|
||||
|
||||
@@ -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": "Налады",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ρυθμίσεις",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "設定",
|
||||
|
||||
@@ -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": "설정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Настройки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "设置",
|
||||
|
||||
@@ -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": "設定",
|
||||
|
||||
+7
@@ -57,6 +57,13 @@
|
||||
@case ('xtream') {
|
||||
<app-xtream-code-import (addClicked)="closeDialog()" />
|
||||
}
|
||||
@case ('auto') {
|
||||
<app-auto-import
|
||||
[initialText]="autoDetectText()"
|
||||
(textChanged)="autoDetectText.set($event)"
|
||||
(candidateSelected)="onCandidateSelected($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+182
-3
@@ -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);
|
||||
}
|
||||
});
|
||||
+125
-1
@@ -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<PlaylistType>('url');
|
||||
|
||||
/**
|
||||
* Candidate picked in the auto-detect surface, waiting for its target
|
||||
* form to exist. Selecting a candidate switches `method`, which only
|
||||
* instantiates the target child on the NEXT change-detection pass — so
|
||||
* the prefill is applied by an effect that re-runs once the child's
|
||||
* viewChild signal resolves, then clears this.
|
||||
*/
|
||||
private readonly pendingPrefill =
|
||||
signal<ProviderImportCandidate | null>(null);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<form class="auto-import" (submit)="$event.preventDefault()">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{ 'HOME.AUTO_DETECT.LABEL' | translate }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
[formControl]="textControl"
|
||||
rows="6"
|
||||
data-test-id="auto-detect-textarea"
|
||||
[placeholder]="'HOME.AUTO_DETECT.PLACEHOLDER' | translate"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<p class="auto-import__privacy">
|
||||
{{ 'HOME.AUTO_DETECT.PRIVACY_HINT' | translate }}
|
||||
</p>
|
||||
|
||||
@if (hasText() && candidates().length === 0) {
|
||||
<p class="auto-import__empty" data-test-id="auto-detect-no-matches">
|
||||
{{ 'HOME.AUTO_DETECT.NO_MATCHES' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@for (candidate of candidates(); track $index) {
|
||||
<div class="auto-import__candidate" data-test-id="auto-detect-candidate">
|
||||
<div class="auto-import__candidate-head">
|
||||
<mat-icon class="auto-import__candidate-icon">
|
||||
{{ kindIcon(candidate.kind) }}
|
||||
</mat-icon>
|
||||
<span class="auto-import__candidate-title">
|
||||
{{ kindLabelKey(candidate.kind) | translate }}
|
||||
</span>
|
||||
<span
|
||||
class="auto-import__confidence"
|
||||
[class]="'auto-import__confidence--' + candidate.confidence"
|
||||
>
|
||||
{{ confidenceLabelKey(candidate.confidence) | translate }}
|
||||
</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
data-test-id="auto-detect-use"
|
||||
(click)="selectCandidate(candidate)"
|
||||
>
|
||||
{{ 'HOME.AUTO_DETECT.USE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (summaryRows(candidate).length > 0) {
|
||||
<dl class="auto-import__rows">
|
||||
@for (row of summaryRows(candidate); track row.labelKey) {
|
||||
<div class="auto-import__row">
|
||||
<dt>{{ row.labelKey | translate }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
} @else {
|
||||
<p class="auto-import__candidate-note">
|
||||
{{ 'HOME.AUTO_DETECT.M3U_TEXT_NOTE' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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<AutoImportComponent>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<ProviderImportKind, string> = {
|
||||
xtream: 'vpn_key',
|
||||
stalker: 'cast',
|
||||
'm3u-url': 'public',
|
||||
'm3u-text': 'subject',
|
||||
};
|
||||
|
||||
const KIND_LABEL_KEYS: Record<ProviderImportKind, string> = {
|
||||
xtream: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_LABEL',
|
||||
stalker: 'HOME.ADD_PLAYLIST.METHOD_STALKER_LABEL',
|
||||
'm3u-url': 'HOME.ADD_PLAYLIST.METHOD_URL_LABEL',
|
||||
'm3u-text': 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL',
|
||||
};
|
||||
|
||||
const CONFIDENCE_LABEL_KEYS: Record<ProviderImportConfidence, string> = {
|
||||
high: 'HOME.AUTO_DETECT.CONFIDENCE_HIGH',
|
||||
medium: 'HOME.AUTO_DETECT.CONFIDENCE_MEDIUM',
|
||||
low: 'HOME.AUTO_DETECT.CONFIDENCE_LOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<ProviderImportCandidate>();
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
|
||||
export type PlaylistType =
|
||||
| 'xtream'
|
||||
| 'url'
|
||||
| 'text'
|
||||
| 'file'
|
||||
| 'stalker'
|
||||
| 'auto';
|
||||
@@ -1,6 +1,12 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type PortalPlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
|
||||
export type PortalPlaylistType =
|
||||
| 'xtream'
|
||||
| 'url'
|
||||
| 'text'
|
||||
| 'file'
|
||||
| 'stalker'
|
||||
| 'auto';
|
||||
|
||||
export interface PortalShellActions {
|
||||
openAddPlaylistDialog(type?: PortalPlaylistType): void;
|
||||
|
||||
@@ -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';
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(candidate)) {
|
||||
if (value !== undefined && value !== '') {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result as unknown as ProviderImportCandidate;
|
||||
}
|
||||
|
||||
function dedupe(
|
||||
candidates: ProviderImportCandidate[]
|
||||
): ProviderImportCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
return candidates.filter((candidate) => {
|
||||
// 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<ProviderImportConfidence, number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
function sortByConfidence(
|
||||
candidates: ProviderImportCandidate[]
|
||||
): ProviderImportCandidate[] {
|
||||
// Array.prototype.sort is stable, so equal-confidence candidates keep
|
||||
// their assembly order: Stalker (strongest signal) → Xtream → M3U.
|
||||
return [...candidates].sort(
|
||||
(a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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`(?<![\p{L}\p{N}_])`;
|
||||
|
||||
/**
|
||||
* What may stand between a label and its value. Reseller messages decorate
|
||||
* this freely — `USER➤ abc`, `PASS ► abc`, `Login → abc` — so besides plain
|
||||
* `:`/`=`/`>` 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}(?<!adult[\\s_-]{0,4})(?<!parent(?:al)?[\\s_-]{0,4})` +
|
||||
String.raw`(?:pass[\s_-]*word|pass|pwd|пароль)` +
|
||||
`${LABEL_SEPARATOR}(${DEFAULT_VALUE})`,
|
||||
'iu'
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'host',
|
||||
pattern: labeledPattern(
|
||||
String.raw`(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?`
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'port',
|
||||
pattern: labeledPattern(String.raw`port|порт`, String.raw`\d{2,5}\b`),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Fresh stateful matcher for a module-level global pattern, so `lastIndex`
|
||||
* never leaks between calls. Callers drive it lazily with `exec` and stop at
|
||||
* their cap — detection runs on every keystroke, and materializing every
|
||||
* match of a pathological paste up front would defeat the caps' purpose.
|
||||
* (Also avoids `String.prototype.matchAll`, which the web build's TS lib
|
||||
* target predates.)
|
||||
*/
|
||||
function freshMatcher(pattern: RegExp): RegExp {
|
||||
return new RegExp(pattern.source, pattern.flags);
|
||||
}
|
||||
|
||||
export function extractUrls(text: string): DetectedUrl[] {
|
||||
const detected: DetectedUrl[] = [];
|
||||
const seen = new Set<string>();
|
||||
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 <hex>" — the marker may sit before or after the
|
||||
* separator, and the separator itself may be plain whitespace ("DeviceID
|
||||
* 1💥2 <hex>"), 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 <hex>" (or "DEVICE ID 1&2: <hex>") 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user