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
Adds a deterministic parser (detectProviderImportCandidates in libs/shared/interfaces) that scans a pasted provider message for URLs, MAC addresses, and labeled credentials/identity fields (username, password, serial, device IDs, signatures), classifies the source as Xtream, Stalker, or an M3U link/body, and returns ranked candidates. Every extracted value is a verbatim substring or a canonical form from an existing shared normalizer; detection only proposes — the existing forms and their behavioral probes (portal discovery, connection test) stay authoritative. The Add playlist dialog gains a sixth "Auto-detect" method with a textarea that runs the parser on every edit and renders candidate cards; picking one switches to the matching import form with the fields prefilled. Detection runs fully locally; pasted text never leaves the app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
35 files changed
+1883
-48
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.
|
||||
@@ -300,7 +300,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **libs/** - Shared libraries:
|
||||
- **epg/data-access** - EPG services, runtime bridge, program normalization
|
||||
- **m3u-state** - NgRx state management for M3U playlists
|
||||
- **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs)
|
||||
- **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs, and the "Auto-detect" method: paste a provider message, `detectProviderImportCandidates` in `libs/shared/interfaces` deterministically extracts URLs/credentials/MAC+device identity and prefills the matching form — detection only proposes, the target form's own validation and behavioral probes stay authoritative)
|
||||
- **playlist/m3u/feature-player** - M3U video player page and `/workspace/playlists/:id` routes
|
||||
- **playlist/shared/{ui,util}** - Shared playlist UI and utilities
|
||||
- **portal/xtream/{data-access,feature}** - XtreamStore, services, data sources; routed Xtream components
|
||||
|
||||
@@ -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": "設定",
|
||||
|
||||
+5
@@ -57,6 +57,11 @@
|
||||
@case ('xtream') {
|
||||
<app-xtream-code-import (addClicked)="closeDialog()" />
|
||||
}
|
||||
@case ('auto') {
|
||||
<app-auto-import
|
||||
(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) {
|
||||
|
||||
+160
-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,158 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
}
|
||||
);
|
||||
|
||||
describe('auto-detect candidate handoff', () => {
|
||||
const applyPrefill = () =>
|
||||
(
|
||||
component as unknown as { applyPendingPrefill(): void }
|
||||
).applyPendingPrefill();
|
||||
|
||||
it.each([
|
||||
['xtream', 'xtream'],
|
||||
['stalker', 'stalker'],
|
||||
['m3u-url', 'url'],
|
||||
['m3u-text', 'text'],
|
||||
] as const)(
|
||||
'switches to the %s import form for a %s candidate',
|
||||
(kind, expectedMethod) => {
|
||||
component.onCandidateSelected({
|
||||
kind,
|
||||
confidence: 'high',
|
||||
} as ProviderImportCandidate);
|
||||
|
||||
expect(component.method()).toBe(expectedMethod);
|
||||
}
|
||||
);
|
||||
|
||||
it('prefills the xtream form once the child exists and clears the pending candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'xtream',
|
||||
confidence: 'high',
|
||||
serverUrl: 'http://tv.example.com:8080',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
suggestedTitle: 'tv.example.com',
|
||||
});
|
||||
applyPrefill();
|
||||
|
||||
expect(patchValue).toHaveBeenCalledWith({
|
||||
title: 'tv.example.com',
|
||||
serverUrl: 'http://tv.example.com:8080',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
});
|
||||
|
||||
// A second run must be a no-op — the candidate was consumed.
|
||||
applyPrefill();
|
||||
expect(patchValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the candidate pending while the target form does not exist yet', () => {
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => undefined
|
||||
);
|
||||
const patchValue = jest.fn();
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'xtream',
|
||||
confidence: 'high',
|
||||
username: 'alice',
|
||||
});
|
||||
applyPrefill();
|
||||
|
||||
// Child appears on a later change-detection pass.
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
applyPrefill();
|
||||
|
||||
expect(patchValue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: 'alice' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prefills the stalker form including identity fields', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { stalkerImport: jest.Mock }).stalkerImport =
|
||||
jest.fn(() => ({ form: { patchValue } }));
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'stalker',
|
||||
confidence: 'high',
|
||||
portalUrl: 'http://stb.example.com/c/',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
serialNumber: 'SN123',
|
||||
deviceId1: 'a'.repeat(64),
|
||||
deviceId2: 'b'.repeat(64),
|
||||
signature1: 'c'.repeat(64),
|
||||
signature2: 'd'.repeat(64),
|
||||
username: 'stbuser',
|
||||
password: 'stbpass',
|
||||
suggestedTitle: 'stb.example.com',
|
||||
});
|
||||
applyPrefill();
|
||||
|
||||
expect(patchValue).toHaveBeenCalledWith({
|
||||
title: 'stb.example.com',
|
||||
portalUrl: 'http://stb.example.com/c/',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
serialNumber: 'SN123',
|
||||
deviceId1: 'a'.repeat(64),
|
||||
deviceId2: 'b'.repeat(64),
|
||||
signature1: 'c'.repeat(64),
|
||||
signature2: 'd'.repeat(64),
|
||||
username: 'stbuser',
|
||||
password: 'stbpass',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefills the URL form for an m3u-url candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
|
||||
form: { patchValue },
|
||||
}));
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'm3u-url',
|
||||
confidence: 'high',
|
||||
url: 'https://lists.example.com/main.m3u',
|
||||
suggestedTitle: 'lists.example.com',
|
||||
});
|
||||
applyPrefill();
|
||||
|
||||
expect(patchValue).toHaveBeenCalledWith({
|
||||
playlistUrl: 'https://lists.example.com/main.m3u',
|
||||
playlistName: 'lists.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefills the raw-text form for an m3u-text candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { textImport: jest.Mock }).textImport = jest.fn(
|
||||
() => ({ textForm: { patchValue } })
|
||||
);
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'm3u-text',
|
||||
confidence: 'high',
|
||||
text: '#EXTM3U\nhttp://streams.example.com/1.ts',
|
||||
});
|
||||
applyPrefill();
|
||||
|
||||
expect(patchValue).toHaveBeenCalledWith({
|
||||
text: '#EXTM3U\nhttp://streams.example.com/1.ts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function selectType(type: PlaylistType): void {
|
||||
// The dialog now uses a single `method` signal across all 5 source
|
||||
// types — no more category × subtype matrix.
|
||||
// The dialog now uses a single `method` signal across all 6 source
|
||||
// methods — no more category × subtype matrix.
|
||||
component.method.set(type);
|
||||
}
|
||||
});
|
||||
+111
-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,20 @@ export class AddPlaylistDialogComponent {
|
||||
readonly textImport = viewChild(TextImportComponent);
|
||||
readonly xtreamImport = viewChild(XtreamCodeImportComponent);
|
||||
readonly stalkerImport = viewChild(StalkerPortalImportComponent);
|
||||
readonly autoImport = viewChild(AutoImportComponent);
|
||||
|
||||
readonly method = signal<PlaylistType>('url');
|
||||
|
||||
/**
|
||||
* Candidate picked in the auto-detect surface, waiting for its target
|
||||
* form to exist. Selecting a candidate switches `method`, which only
|
||||
* instantiates the target child on the NEXT change-detection pass — so
|
||||
* the prefill is applied by an effect that re-runs once the child's
|
||||
* viewChild signal resolves, then clears this.
|
||||
*/
|
||||
private readonly pendingPrefill =
|
||||
signal<ProviderImportCandidate | null>(null);
|
||||
|
||||
// Order matches the v0.22 mockup left-to-right: URL first (Most common),
|
||||
// then File, Xtream credentials, Stalker portal, raw text paste. Each
|
||||
// entry stands on its own — no nested subtypes. Labels are short and
|
||||
@@ -110,6 +138,12 @@ export class AddPlaylistDialogComponent {
|
||||
labelKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL',
|
||||
subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB',
|
||||
},
|
||||
{
|
||||
value: 'auto',
|
||||
icon: 'auto_awesome',
|
||||
labelKey: 'HOME.ADD_PLAYLIST.METHOD_AUTO_LABEL',
|
||||
subKey: 'HOME.ADD_PLAYLIST.METHOD_AUTO_SUB',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -122,6 +156,79 @@ export class AddPlaylistDialogComponent {
|
||||
if (this.data?.type) {
|
||||
this.method.set(this.data.type);
|
||||
}
|
||||
effect(() => this.applyPendingPrefill());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a detected source to the matching import form. Only prefills —
|
||||
* the user reviews and submits through the regular form, so validation
|
||||
* and the behavioral probes (portal discovery, connection test) stay in
|
||||
* charge of what actually gets persisted.
|
||||
*/
|
||||
onCandidateSelected(candidate: ProviderImportCandidate): void {
|
||||
this.pendingPrefill.set(candidate);
|
||||
this.method.set(METHOD_BY_CANDIDATE_KIND[candidate.kind]);
|
||||
}
|
||||
|
||||
private applyPendingPrefill(): void {
|
||||
const candidate = this.pendingPrefill();
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
switch (candidate.kind) {
|
||||
case 'm3u-url': {
|
||||
const child = this.urlUpload();
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
child.form.patchValue({
|
||||
playlistUrl: candidate.url ?? '',
|
||||
playlistName: candidate.suggestedTitle ?? '',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'm3u-text': {
|
||||
const child = this.textImport();
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
child.textForm.patchValue({ text: candidate.text ?? '' });
|
||||
break;
|
||||
}
|
||||
case 'xtream': {
|
||||
const child = this.xtreamImport();
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
child.form.patchValue({
|
||||
title: candidate.suggestedTitle ?? '',
|
||||
serverUrl: candidate.serverUrl ?? '',
|
||||
username: candidate.username ?? '',
|
||||
password: candidate.password ?? '',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'stalker': {
|
||||
const child = this.stalkerImport();
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
child.form.patchValue({
|
||||
title: candidate.suggestedTitle ?? '',
|
||||
portalUrl: candidate.portalUrl ?? '',
|
||||
macAddress: candidate.macAddress ?? '',
|
||||
serialNumber: candidate.serialNumber ?? '',
|
||||
deviceId1: candidate.deviceId1 ?? '',
|
||||
deviceId2: candidate.deviceId2 ?? '',
|
||||
signature1: candidate.signature1 ?? '',
|
||||
signature2: candidate.signature2 ?? '',
|
||||
username: candidate.username ?? '',
|
||||
password: candidate.password ?? '',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.pendingPrefill.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +303,9 @@ export class AddPlaylistDialogComponent {
|
||||
case 'stalker':
|
||||
this.stalkerImport()?.clearForm();
|
||||
break;
|
||||
case 'auto':
|
||||
this.autoImport()?.clearForm();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<form class="auto-import" (submit)="$event.preventDefault()">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{ 'HOME.AUTO_DETECT.LABEL' | translate }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
[formControl]="textControl"
|
||||
rows="6"
|
||||
data-test-id="auto-detect-textarea"
|
||||
[placeholder]="'HOME.AUTO_DETECT.PLACEHOLDER' | translate"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<p class="auto-import__privacy">
|
||||
{{ 'HOME.AUTO_DETECT.PRIVACY_HINT' | translate }}
|
||||
</p>
|
||||
|
||||
@if (hasText() && candidates().length === 0) {
|
||||
<p class="auto-import__empty" data-test-id="auto-detect-no-matches">
|
||||
{{ 'HOME.AUTO_DETECT.NO_MATCHES' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@for (candidate of candidates(); track $index) {
|
||||
<div class="auto-import__candidate" data-test-id="auto-detect-candidate">
|
||||
<div class="auto-import__candidate-head">
|
||||
<mat-icon class="auto-import__candidate-icon">
|
||||
{{ kindIcon(candidate.kind) }}
|
||||
</mat-icon>
|
||||
<span class="auto-import__candidate-title">
|
||||
{{ kindLabelKey(candidate.kind) | translate }}
|
||||
</span>
|
||||
<span
|
||||
class="auto-import__confidence"
|
||||
[class]="'auto-import__confidence--' + candidate.confidence"
|
||||
>
|
||||
{{ confidenceLabelKey(candidate.confidence) | translate }}
|
||||
</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
data-test-id="auto-detect-use"
|
||||
(click)="selectCandidate(candidate)"
|
||||
>
|
||||
{{ 'HOME.AUTO_DETECT.USE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (summaryRows(candidate).length > 0) {
|
||||
<dl class="auto-import__rows">
|
||||
@for (row of summaryRows(candidate); track row.labelKey) {
|
||||
<div class="auto-import__row">
|
||||
<dt>{{ row.labelKey | translate }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
} @else {
|
||||
<p class="auto-import__candidate-note">
|
||||
{{ 'HOME.AUTO_DETECT.M3U_TEXT_NOTE' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
@@ -0,0 +1,98 @@
|
||||
:host {
|
||||
display: flex;
|
||||
margin: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auto-import {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auto-import__privacy {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--app-muted-color);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.auto-import__empty {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.auto-import__candidate {
|
||||
border: 1px solid var(--app-widget-header-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: color-mix(in srgb, var(--app-shell-bg) 60%, transparent);
|
||||
}
|
||||
|
||||
.auto-import__candidate-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auto-import__candidate-icon {
|
||||
color: var(--app-selection-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auto-import__candidate-title {
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.auto-import__confidence {
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--app-widget-header-border);
|
||||
color: var(--app-muted-color);
|
||||
margin-right: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-import__confidence--high {
|
||||
color: var(--app-selection-color);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 45%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.auto-import__rows {
|
||||
margin: 10px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auto-import__row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
|
||||
dt {
|
||||
flex: 0 0 130px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--app-body-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-import__candidate-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Component, computed, output } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
detectProviderImportCandidates,
|
||||
ProviderImportCandidate,
|
||||
ProviderImportConfidence,
|
||||
ProviderImportKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
interface CandidateSummaryRow {
|
||||
labelKey: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const KIND_ICONS: Record<ProviderImportKind, string> = {
|
||||
xtream: 'vpn_key',
|
||||
stalker: 'cast',
|
||||
'm3u-url': 'public',
|
||||
'm3u-text': 'subject',
|
||||
};
|
||||
|
||||
const KIND_LABEL_KEYS: Record<ProviderImportKind, string> = {
|
||||
xtream: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_LABEL',
|
||||
stalker: 'HOME.ADD_PLAYLIST.METHOD_STALKER_LABEL',
|
||||
'm3u-url': 'HOME.ADD_PLAYLIST.METHOD_URL_LABEL',
|
||||
'm3u-text': 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL',
|
||||
};
|
||||
|
||||
const CONFIDENCE_LABEL_KEYS: Record<ProviderImportConfidence, string> = {
|
||||
high: 'HOME.AUTO_DETECT.CONFIDENCE_HIGH',
|
||||
medium: 'HOME.AUTO_DETECT.CONFIDENCE_MEDIUM',
|
||||
low: 'HOME.AUTO_DETECT.CONFIDENCE_LOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* "Paste anything from your provider" surface: a free-text area run through
|
||||
* the deterministic `detectProviderImportCandidates` on every edit. Each
|
||||
* recognized source renders as a card; picking one hands the candidate to the
|
||||
* dialog, which switches to the matching import form with the fields
|
||||
* prefilled. Detection never adds a playlist by itself — the existing forms
|
||||
* (and their behavioral probes) stay the single path into the store.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-auto-import',
|
||||
templateUrl: './auto-import.component.html',
|
||||
styleUrl: './auto-import.component.scss',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class AutoImportComponent {
|
||||
readonly candidateSelected = output<ProviderImportCandidate>();
|
||||
|
||||
readonly textControl = new FormControl('', { nonNullable: true });
|
||||
|
||||
private readonly textValue = toSignal(this.textControl.valueChanges, {
|
||||
initialValue: '',
|
||||
});
|
||||
|
||||
readonly candidates = computed(() =>
|
||||
detectProviderImportCandidates(this.textValue())
|
||||
);
|
||||
|
||||
readonly hasText = computed(() => this.textValue().trim().length > 0);
|
||||
|
||||
clearForm(): void {
|
||||
this.textControl.setValue('');
|
||||
}
|
||||
|
||||
selectCandidate(candidate: ProviderImportCandidate): void {
|
||||
this.candidateSelected.emit(candidate);
|
||||
}
|
||||
|
||||
kindIcon(kind: ProviderImportKind): string {
|
||||
return KIND_ICONS[kind];
|
||||
}
|
||||
|
||||
kindLabelKey(kind: ProviderImportKind): string {
|
||||
return KIND_LABEL_KEYS[kind];
|
||||
}
|
||||
|
||||
confidenceLabelKey(confidence: ProviderImportConfidence): string {
|
||||
return CONFIDENCE_LABEL_KEYS[confidence];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows shown on a candidate card. Labels reuse the import forms' own
|
||||
* translation keys so the card vocabulary matches the form it prefills.
|
||||
* The password is never rendered — its presence is stated with a mask.
|
||||
*/
|
||||
summaryRows(candidate: ProviderImportCandidate): CandidateSummaryRow[] {
|
||||
const rows: CandidateSummaryRow[] = [];
|
||||
const push = (labelKey: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
rows.push({ labelKey, value });
|
||||
}
|
||||
};
|
||||
switch (candidate.kind) {
|
||||
case 'm3u-url':
|
||||
push('HOME.URL_UPLOAD.PLAYLIST_URL', candidate.url);
|
||||
break;
|
||||
case 'xtream':
|
||||
push(
|
||||
'HOME.XTREAM_PLAYLIST.SERVER_URL',
|
||||
candidate.serverUrl
|
||||
);
|
||||
push('HOME.XTREAM_PLAYLIST.USERNAME', candidate.username);
|
||||
push(
|
||||
'HOME.XTREAM_PLAYLIST.PASSWORD',
|
||||
candidate.password ? '••••••' : undefined
|
||||
);
|
||||
break;
|
||||
case 'stalker':
|
||||
push(
|
||||
'HOME.STALKER_PORTAL.SERVER_URL',
|
||||
candidate.portalUrl
|
||||
);
|
||||
push(
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS',
|
||||
candidate.macAddress
|
||||
);
|
||||
push(
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER',
|
||||
candidate.serialNumber
|
||||
);
|
||||
push('HOME.STALKER_PORTAL.DEVICE_ID_1', candidate.deviceId1);
|
||||
push('HOME.STALKER_PORTAL.DEVICE_ID_2', candidate.deviceId2);
|
||||
push('HOME.STALKER_PORTAL.SIGNATURE_1', candidate.signature1);
|
||||
push('HOME.STALKER_PORTAL.SIGNATURE_2', candidate.signature2);
|
||||
push('HOME.XTREAM_PLAYLIST.USERNAME', candidate.username);
|
||||
push(
|
||||
'HOME.XTREAM_PLAYLIST.PASSWORD',
|
||||
candidate.password ? '••••••' : undefined
|
||||
);
|
||||
break;
|
||||
case 'm3u-text':
|
||||
break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
|
||||
export type PlaylistType =
|
||||
| 'xtream'
|
||||
| 'url'
|
||||
| 'text'
|
||||
| 'file'
|
||||
| 'stalker'
|
||||
| 'auto';
|
||||
@@ -1,6 +1,12 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type PortalPlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
|
||||
export type PortalPlaylistType =
|
||||
| 'xtream'
|
||||
| 'url'
|
||||
| 'text'
|
||||
| 'file'
|
||||
| 'stalker'
|
||||
| 'auto';
|
||||
|
||||
export interface PortalShellActions {
|
||||
openAddPlaylistDialog(type?: PortalPlaylistType): void;
|
||||
|
||||
@@ -35,6 +35,7 @@ export * from './lib/portal-activity-item.interface';
|
||||
export * from './lib/portal-debug.interface';
|
||||
export * from './lib/playlist-display-label.util';
|
||||
export * from './lib/portal-playback.interface';
|
||||
export * from './lib/provider-import-detection.util';
|
||||
export * from './lib/provider-overview.util';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
detectProviderImportCandidates,
|
||||
ProviderImportCandidate,
|
||||
} from './provider-import-detection.util';
|
||||
|
||||
describe('detectProviderImportCandidates', () => {
|
||||
const only = (
|
||||
candidates: ProviderImportCandidate[],
|
||||
kind: ProviderImportCandidate['kind']
|
||||
) => candidates.filter((candidate) => candidate.kind === kind);
|
||||
|
||||
it('returns nothing for empty or blank input', () => {
|
||||
expect(detectProviderImportCandidates('')).toEqual([]);
|
||||
expect(detectProviderImportCandidates(' \n\t ')).toEqual([]);
|
||||
expect(
|
||||
detectProviderImportCandidates(undefined as unknown as string)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for prose without credentials or links', () => {
|
||||
expect(
|
||||
detectProviderImportCandidates(
|
||||
'Hello! Thanks for your purchase, enjoy the service.'
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
describe('xtream detection', () => {
|
||||
it('detects a get.php URL and mines the credentials from its query', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'Your playlist: http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus&output=ts'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
serverUrl: 'http://tv.example.com:8080',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
suggestedTitle: 'tv.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('offers a get.php link with credentials as a low-ranked plain M3U too', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
|
||||
);
|
||||
|
||||
const m3u = only(candidates, 'm3u-url');
|
||||
expect(m3u).toHaveLength(1);
|
||||
expect(m3u[0].confidence).toBe('low');
|
||||
expect(m3u[0].url).toBe(
|
||||
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
|
||||
);
|
||||
});
|
||||
|
||||
it('combines a bare player_api.php URL with labeled credentials', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Server: http://panel.example.org/player_api.php',
|
||||
'Username: bob',
|
||||
'Password: hunter2',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
serverUrl: 'http://panel.example.org',
|
||||
username: 'bob',
|
||||
password: 'hunter2',
|
||||
});
|
||||
});
|
||||
|
||||
it('leans xtream for labeled username+password with a labeled host and port', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Host: super.tv',
|
||||
'Port: 8080',
|
||||
'User: carol',
|
||||
'Pass: pa55w0rd',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'medium',
|
||||
serverUrl: 'http://super.tv:8080',
|
||||
username: 'carol',
|
||||
password: 'pa55w0rd',
|
||||
suggestedTitle: 'super.tv',
|
||||
});
|
||||
});
|
||||
|
||||
it('still proposes xtream from a credential pair with no server at all', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'login: dave\nпароль: qwerty123'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0].confidence).toBe('low');
|
||||
expect(xtream[0].username).toBe('dave');
|
||||
expect(xtream[0].password).toBe('qwerty123');
|
||||
expect(xtream[0].serverUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('understands Russian labels', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Сервер: http://ru.example.net:2500',
|
||||
'Логин: ivan',
|
||||
'Пароль: secret42',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
serverUrl: 'http://ru.example.net:2500',
|
||||
username: 'ivan',
|
||||
password: 'secret42',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats credentials in the query of a generic URL as xtream', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://portal.example.io/api?username=eve&password=pw&foo=bar'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0].username).toBe('eve');
|
||||
expect(xtream[0].password).toBe('pw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stalker detection', () => {
|
||||
it('pairs a MAC with a portal-shaped URL at high confidence', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Portal: http://stb.example.com/stalker_portal/c/',
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
portalUrl: 'http://stb.example.com/stalker_portal/c/',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
suggestedTitle: 'stb.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts /c/ and portal.php shaped URLs as portals', () => {
|
||||
for (const url of [
|
||||
'http://p.example.com/c/',
|
||||
'http://p.example.com/portal.php',
|
||||
'http://p.example.com/server/load.php',
|
||||
]) {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
`${url}\n00-1A-79-AB-CD-EF`
|
||||
);
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0]?.portalUrl).toBe(url);
|
||||
expect(stalker[0]?.confidence).toBe('high');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a generic URL at medium confidence', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://somehost.example.net/tv\nmac: 00:1a:79:aa:bb:cc'
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0].confidence).toBe('medium');
|
||||
expect(stalker[0].portalUrl).toBe('http://somehost.example.net/tv');
|
||||
expect(stalker[0].macAddress).toBe('00:1A:79:AA:BB:CC');
|
||||
});
|
||||
|
||||
it('proposes a low-confidence stalker candidate for a lone MAC', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'Ваш MAC 00:1A:79:00:11:22 активирован'
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0].confidence).toBe('low');
|
||||
expect(stalker[0].portalUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognizes a bare Infomir MAC but not a bare foreign 12-hex run', () => {
|
||||
const infomir = detectProviderImportCandidates('mac 001a79deadbe');
|
||||
expect(only(infomir, 'stalker')).toHaveLength(1);
|
||||
expect(only(infomir, 'stalker')[0].macAddress).toBe(
|
||||
'00:1A:79:DE:AD:BE'
|
||||
);
|
||||
|
||||
const foreign = detectProviderImportCandidates(
|
||||
'token aabbccddeeff issued'
|
||||
);
|
||||
expect(only(foreign, 'stalker')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts a labeled non-Infomir MAC', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'MAC: A0:B1:C2:D3:E4:F5\nhttp://p.example.com/c/'
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0].macAddress).toBe('A0:B1:C2:D3:E4:F5');
|
||||
});
|
||||
|
||||
it('captures serial, device IDs and signatures', () => {
|
||||
const deviceId1 = 'a'.repeat(64);
|
||||
const deviceId2 = 'b'.repeat(64);
|
||||
const signature1 = 'c'.repeat(64);
|
||||
const signature2 = 'd'.repeat(64);
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Portal: http://stb.example.com/c/',
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
'Serial Number: 0123456789AB',
|
||||
`Device ID: ${deviceId1}`,
|
||||
`Device ID 2: ${deviceId2}`,
|
||||
`Signature: ${signature1}`,
|
||||
`Signature 2: ${signature2}`,
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0]).toMatchObject({
|
||||
serialNumber: '0123456789AB',
|
||||
deviceId1,
|
||||
deviceId2,
|
||||
signature1,
|
||||
signature2,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-hex device IDs and signatures instead of guessing', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
'Device ID: see-attachment',
|
||||
'Signature: n/a',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0].deviceId1).toBeUndefined();
|
||||
expect(stalker[0].signature1).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not read a MAC out of a 64-hex device ID blob', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
`device id: ${'001a79'.repeat(10)}aabb`
|
||||
);
|
||||
|
||||
expect(only(candidates, 'stalker')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('produces one candidate per MAC when several are handed out', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'http://multi.example.com/c/',
|
||||
'00:1A:79:11:11:11',
|
||||
'00:1A:79:22:22:22',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(2);
|
||||
expect(stalker.map((candidate) => candidate.macAddress)).toEqual([
|
||||
'00:1A:79:11:11:11',
|
||||
'00:1A:79:22:22:22',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives labeled credentials to the stalker candidate, not a competing xtream guess', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Portal: http://stb.example.com/c/',
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
'Login: stbuser',
|
||||
'Password: stbpass',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(only(candidates, 'xtream')).toHaveLength(0);
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0].username).toBe('stbuser');
|
||||
expect(stalker[0].password).toBe('stbpass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('m3u detection', () => {
|
||||
it('detects .m3u and .m3u8 links at high confidence', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'List 1: https://lists.example.com/main.m3u\nList 2: https://lists.example.com/backup.m3u8'
|
||||
);
|
||||
|
||||
const m3u = only(candidates, 'm3u-url');
|
||||
expect(m3u).toHaveLength(2);
|
||||
expect(m3u.every((c) => c.confidence === 'high')).toBe(true);
|
||||
expect(m3u[0].url).toBe('https://lists.example.com/main.m3u');
|
||||
expect(m3u[0].suggestedTitle).toBe('lists.example.com');
|
||||
});
|
||||
|
||||
it('strips trailing punctuation from a link found in prose', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'Your playlist (https://lists.example.com/main.m3u).'
|
||||
);
|
||||
|
||||
expect(only(candidates, 'm3u-url')[0].url).toBe(
|
||||
'https://lists.example.com/main.m3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not turn an .m3u link with query credentials into an xtream server', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'https://lists.example.com/files/list.m3u8?username=u&password=p'
|
||||
);
|
||||
|
||||
expect(only(candidates, 'xtream')).toHaveLength(0);
|
||||
expect(only(candidates, 'm3u-url')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('routes a pasted playlist body to the raw-text import', () => {
|
||||
const body =
|
||||
'#EXTM3U\n#EXTINF:-1 tvg-id="one",Channel One\nhttp://streams.example.com/1.ts';
|
||||
const candidates = detectProviderImportCandidates(body);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{ kind: 'm3u-text', confidence: 'high', text: body },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed messages and ranking', () => {
|
||||
it('surfaces every source found in a combined provider message', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Xtream: http://x.example.com:8080/get.php?username=u1&password=p1',
|
||||
'Backup M3U: http://x.example.com:8080/files/backup.m3u',
|
||||
'Stalker portal: http://s.example.com/c/',
|
||||
'MAC: 00:1A:79:99:88:77',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(only(candidates, 'stalker')).toHaveLength(1);
|
||||
expect(only(candidates, 'xtream')).toHaveLength(1);
|
||||
expect(only(candidates, 'm3u-url').length).toBeGreaterThanOrEqual(1);
|
||||
// High-confidence candidates sort ahead of low-confidence ones.
|
||||
const ranks = candidates.map((candidate) => candidate.confidence);
|
||||
const firstLow = ranks.indexOf('low');
|
||||
const lastHigh = ranks.lastIndexOf('high');
|
||||
expect(firstLow === -1 || lastHigh < firstLow).toBe(true);
|
||||
});
|
||||
|
||||
it('dedupes an URL pasted twice', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'https://lists.example.com/main.m3u\nhttps://lists.example.com/main.m3u'
|
||||
);
|
||||
|
||||
expect(only(candidates, 'm3u-url')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps the number of returned candidates', () => {
|
||||
const urls = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) => `https://lists.example.com/list-${index}.m3u`
|
||||
).join('\n');
|
||||
|
||||
expect(
|
||||
detectProviderImportCandidates(urls).length
|
||||
).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
DetectedUrl,
|
||||
extractLabeledFields,
|
||||
extractMacAddresses,
|
||||
extractUrls,
|
||||
LabeledFields,
|
||||
labeledHostUrl,
|
||||
} from './provider-import-scan.util';
|
||||
import {
|
||||
extractXtreamCredentialsFromUrl,
|
||||
normalizeXtreamServerUrl,
|
||||
} from './xtream-portal.utils';
|
||||
|
||||
/**
|
||||
* Deterministic detection of playlist sources inside a pasted provider
|
||||
* message ("here are your credentials" mails, reseller chat messages, etc.).
|
||||
*
|
||||
* Design constraints, in order:
|
||||
*
|
||||
* 1. **Every value is a verbatim substring of the input** (or a canonical
|
||||
* form produced by an existing shared normalizer such as
|
||||
* `normalizeStalkerMacAddress`). Nothing here invents, completes, or
|
||||
* "fixes" a credential — a password that is almost right is wrong.
|
||||
* 2. **Detection proposes, it never decides.** The output prefills the
|
||||
* existing import forms; classification authority stays with the
|
||||
* behavioral probes those forms already run (portal discovery, Xtream
|
||||
* status check). URL-shape guessing shipped broken Stalker configs three
|
||||
* times (#850/#686/#755) — this util must not become a fourth copy.
|
||||
* 3. Heuristics are ranked by strength: a MAC address is the strongest
|
||||
* signal (→ Stalker), a `get.php`/`player_api.php` URL is next
|
||||
* (→ Xtream, credentials ride in the query), a labeled
|
||||
* username+password pair without a MAC leans Xtream, and `.m3u`
|
||||
* extensions mean a plain playlist URL.
|
||||
*
|
||||
* The low-level scanners live in `provider-import-scan.util.ts`.
|
||||
*/
|
||||
export type ProviderImportKind = 'xtream' | 'stalker' | 'm3u-url' | 'm3u-text';
|
||||
|
||||
export type ProviderImportConfidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface ProviderImportCandidate {
|
||||
kind: ProviderImportKind;
|
||||
confidence: ProviderImportConfidence;
|
||||
/** Suggested playlist title — the hostname of the detected source. */
|
||||
suggestedTitle?: string;
|
||||
/** `m3u-url`: the playlist link exactly as found in the text. */
|
||||
url?: string;
|
||||
/** `m3u-text`: the pasted playlist body itself. */
|
||||
text?: string;
|
||||
/** `xtream`: server base URL (API suffix stripped). */
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
/** `stalker` */
|
||||
portalUrl?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
}
|
||||
|
||||
const GET_PHP_PATH_PATTERN = /\/get\.php$/i;
|
||||
const EXTM3U_HEADER_PATTERN = /^\s*#EXTM3U/i;
|
||||
const MAX_CANDIDATES = 6;
|
||||
|
||||
/**
|
||||
* Scans a pasted provider message and returns ranked import candidates.
|
||||
* Pure and synchronous — safe to run on every keystroke.
|
||||
*/
|
||||
export function detectProviderImportCandidates(
|
||||
text: string
|
||||
): ProviderImportCandidate[] {
|
||||
const raw = typeof text === 'string' ? text : '';
|
||||
if (!raw.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// A raw playlist body pasted wholesale: every URL inside is a channel
|
||||
// stream, not a source, so URL mining would only produce noise. Route the
|
||||
// whole paste to the raw-text import instead.
|
||||
if (EXTM3U_HEADER_PATTERN.test(raw)) {
|
||||
return [{ kind: 'm3u-text', confidence: 'high', text: raw }];
|
||||
}
|
||||
|
||||
const urls = extractUrls(raw);
|
||||
const labeled = extractLabeledFields(raw, urls);
|
||||
const macs = extractMacAddresses(raw, labeled.macAddress);
|
||||
const candidates: ProviderImportCandidate[] = [];
|
||||
|
||||
// ── Stalker: a MAC address is the strongest signal there is. Labeled
|
||||
// credentials attach here (Stalker portals can require a login too)
|
||||
// rather than seeding a competing Xtream guess.
|
||||
const portal = pickStalkerPortalUrl(urls, labeled);
|
||||
for (const macAddress of macs) {
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'stalker',
|
||||
confidence: portal?.confidence ?? 'low',
|
||||
portalUrl: portal?.url,
|
||||
macAddress,
|
||||
serialNumber: labeled.serialNumber,
|
||||
deviceId1: labeled.deviceId1,
|
||||
deviceId2: labeled.deviceId2,
|
||||
signature1: labeled.signature1,
|
||||
signature2: labeled.signature2,
|
||||
username: labeled.username,
|
||||
password: labeled.password,
|
||||
suggestedTitle: hostnameOf(portal?.url),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Xtream from URLs: an API-shaped path, or credentials in the query of
|
||||
// any non-playlist URL. A `.m3u` link with query credentials stays an M3U
|
||||
// candidate — its path would corrupt the derived server URL.
|
||||
for (const url of urls) {
|
||||
if (url.role === 'm3u' || url.role === 'stalker') {
|
||||
continue;
|
||||
}
|
||||
const creds = extractXtreamCredentialsFromUrl(url.raw);
|
||||
if (url.role !== 'xtream-api' && !creds) {
|
||||
continue;
|
||||
}
|
||||
const username =
|
||||
creds?.username ??
|
||||
(macs.length === 0 ? labeled.username : undefined);
|
||||
const password =
|
||||
creds?.password ??
|
||||
(macs.length === 0 ? labeled.password : undefined);
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'xtream',
|
||||
confidence: username && password ? 'high' : 'medium',
|
||||
serverUrl: safeXtreamServerUrl(url.raw),
|
||||
username,
|
||||
password,
|
||||
suggestedTitle: url.parsed.hostname,
|
||||
})
|
||||
);
|
||||
// `get.php` doubles as the M3U download endpoint, so the same link is
|
||||
// honestly offered as a plain playlist too — ranked low because the
|
||||
// Xtream import unlocks VOD/series where the flat M3U does not.
|
||||
if (creds && GET_PHP_PATH_PATTERN.test(url.parsed.pathname)) {
|
||||
candidates.push({
|
||||
kind: 'm3u-url',
|
||||
confidence: 'low',
|
||||
url: url.raw,
|
||||
suggestedTitle: url.parsed.hostname,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Xtream from labeled credentials alone (username + password with no
|
||||
// MAC in sight leans Xtream), attached to the best available server URL.
|
||||
if (
|
||||
macs.length === 0 &&
|
||||
labeled.username &&
|
||||
labeled.password &&
|
||||
!candidates.some((candidate) => candidate.kind === 'xtream')
|
||||
) {
|
||||
const serverUrl =
|
||||
labeledHostUrl(labeled) ??
|
||||
urls.find((url) => url.role === 'generic')?.raw;
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'xtream',
|
||||
confidence: serverUrl ? 'medium' : 'low',
|
||||
serverUrl: serverUrl
|
||||
? safeXtreamServerUrl(serverUrl)
|
||||
: undefined,
|
||||
username: labeled.username,
|
||||
password: labeled.password,
|
||||
suggestedTitle: hostnameOf(serverUrl),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plain playlist links.
|
||||
for (const url of urls) {
|
||||
if (url.role !== 'm3u') {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
kind: 'm3u-url',
|
||||
confidence: 'high',
|
||||
url: url.raw,
|
||||
suggestedTitle: url.parsed.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
return sortByConfidence(dedupe(candidates)).slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
function pickStalkerPortalUrl(
|
||||
urls: DetectedUrl[],
|
||||
labeled: LabeledFields
|
||||
): { url: string; confidence: ProviderImportConfidence } | null {
|
||||
const shaped = urls.find((url) => url.role === 'stalker');
|
||||
if (shaped) {
|
||||
return { url: shaped.raw, confidence: 'high' };
|
||||
}
|
||||
const labeledUrl = labeledHostUrl(labeled);
|
||||
if (labeledUrl) {
|
||||
return { url: labeledUrl, confidence: 'high' };
|
||||
}
|
||||
const generic = urls.find((url) => url.role === 'generic');
|
||||
if (generic) {
|
||||
return { url: generic.raw, confidence: 'medium' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeXtreamServerUrl(raw: string): string | undefined {
|
||||
try {
|
||||
return normalizeXtreamServerUrl(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hostnameOf(url: string | undefined): string | undefined {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops undefined/empty fields so candidates stay clean to render and diff. */
|
||||
function compact(candidate: ProviderImportCandidate): ProviderImportCandidate {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(candidate)) {
|
||||
if (value !== undefined && value !== '') {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result as unknown as ProviderImportCandidate;
|
||||
}
|
||||
|
||||
function dedupe(
|
||||
candidates: ProviderImportCandidate[]
|
||||
): ProviderImportCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
return candidates.filter((candidate) => {
|
||||
const key = [
|
||||
candidate.kind,
|
||||
candidate.url ?? '',
|
||||
candidate.serverUrl ?? '',
|
||||
candidate.username ?? '',
|
||||
candidate.portalUrl ?? '',
|
||||
candidate.macAddress ?? '',
|
||||
].join('|');
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const CONFIDENCE_RANK: Record<ProviderImportConfidence, number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
};
|
||||
|
||||
function sortByConfidence(
|
||||
candidates: ProviderImportCandidate[]
|
||||
): ProviderImportCandidate[] {
|
||||
// Array.prototype.sort is stable, so equal-confidence candidates keep
|
||||
// their assembly order: Stalker (strongest signal) → Xtream → M3U.
|
||||
return [...candidates].sort(
|
||||
(a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { normalizeStalkerMacAddress } from './stalker-mac-address.util';
|
||||
import { isFullStalkerPortalUrl } from './stalker-portal-mode.util';
|
||||
|
||||
/**
|
||||
* Low-level scanners for the provider-message auto-detect
|
||||
* (`provider-import-detection.util.ts` assembles their findings into
|
||||
* candidates). Everything here returns verbatim substrings of the input, or
|
||||
* canonical forms produced by the existing shared normalizers.
|
||||
*/
|
||||
|
||||
export type UrlRole = 'xtream-api' | 'm3u' | 'stalker' | 'generic';
|
||||
|
||||
export interface DetectedUrl {
|
||||
/** Exact substring as found, trailing punctuation stripped. */
|
||||
raw: string;
|
||||
parsed: URL;
|
||||
role: UrlRole;
|
||||
}
|
||||
|
||||
export interface LabeledFields {
|
||||
username?: string;
|
||||
password?: string;
|
||||
/** Raw value of a server/portal/host/url label — URL- or host-shaped. */
|
||||
host?: string;
|
||||
port?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
}
|
||||
|
||||
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
|
||||
const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/;
|
||||
const M3U_PATH_PATTERN = /\.m3u8?$/i;
|
||||
const XTREAM_API_PATH_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
|
||||
const STALKER_PATH_PATTERN = /\/(?:portal\.php|c\/?)$/i;
|
||||
|
||||
// Uniform-separator MAC (00:1A:79:AA:BB:CC, hyphens or dots alike). The
|
||||
// backreference rejects mixed separators, and because `\b` never sits between
|
||||
// two word characters a MAC-shaped window inside a longer hex blob (a 64-hex
|
||||
// device ID) cannot match.
|
||||
const SEPARATED_MAC_PATTERN = /\b[0-9A-F]{2}([:.-])[0-9A-F]{2}(?:\1[0-9A-F]{2}){4}\b/gi;
|
||||
// A bare 12-hex run is only trusted as a MAC when it carries Infomir's OUI —
|
||||
// any 12 hex digits would false-positive on ids, tokens and hashes.
|
||||
const BARE_INFOMIR_MAC_PATTERN = /\b001A79[0-9A-F]{6}\b/gi;
|
||||
|
||||
/** Hex-shaped identity values (device IDs / signatures are SHA-256 hex). */
|
||||
const HEX_IDENTITY_PATTERN = /^[0-9A-Fa-f]{16,64}$/;
|
||||
const HOSTISH_PATTERN = /^[a-z0-9][a-z0-9.-]*(?::\d{2,5})?$/i;
|
||||
|
||||
const WRAPPING_CHARS = /^["'`«<([]+|["'`»>)\]]+$/g;
|
||||
|
||||
const MAX_URLS = 8;
|
||||
const MAX_MACS = 4;
|
||||
|
||||
interface FieldMatcher {
|
||||
field: keyof LabeledFields;
|
||||
pattern: RegExp;
|
||||
validate?: (value: string) => boolean;
|
||||
}
|
||||
|
||||
// Order matters only for readability — each matcher targets its own field and
|
||||
// the numbered variants ("device id 2") are structured so the unnumbered
|
||||
// pattern cannot swallow them (its optional `1` never matches a literal `2`,
|
||||
// and the required `:`/`=` separator then fails on the digit).
|
||||
//
|
||||
// All matchers require an explicit `:` or `=` separator. Provider messages
|
||||
// overwhelmingly use "Label: value"; a bare "login example" in prose is
|
||||
// exactly the false positive the separator rule exists to reject. The label
|
||||
// vocabulary is deliberately small (English + Russian) until a corpus of
|
||||
// real provider messages justifies growing it.
|
||||
const FIELD_MATCHERS: FieldMatcher[] = [
|
||||
{
|
||||
field: 'deviceId2',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'deviceId1',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'signature2',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'signature1',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'serialNumber',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:serial[\s_-]*(?:number|no)?|s\/n|sn|серийный(?:[\s_-]*номер)?)\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => value.length <= 64,
|
||||
},
|
||||
{
|
||||
field: 'macAddress',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:mac(?:[\s_-]*address)?|мак(?:[\s_-]*адрес)?)\s*[:=]\s*([0-9A-Fa-f:. -]{12,23})/iu,
|
||||
validate: (value) => normalizeStalkerMacAddress(value) !== null,
|
||||
},
|
||||
{
|
||||
field: 'username',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:user[\s_-]*name|login|user|логин|пользователь)\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:pass[\s_-]*word|pass|pwd|пароль)\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
},
|
||||
{
|
||||
field: 'host',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
},
|
||||
{
|
||||
field: 'port',
|
||||
pattern: /(?<![\p{L}\p{N}_])(?:port|порт)\s*[:=]\s*(\d{2,5})\b/iu,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* `String.prototype.matchAll` equivalent — the web build's TS lib target
|
||||
* predates it. Works on a fresh RegExp copy so the module-level patterns
|
||||
* never carry `lastIndex` state between calls.
|
||||
*/
|
||||
function allMatches(pattern: RegExp, text: string): RegExpExecArray[] {
|
||||
const matcher = new RegExp(pattern.source, pattern.flags);
|
||||
const matches: RegExpExecArray[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = matcher.exec(text)) !== null) {
|
||||
matches.push(match);
|
||||
if (match[0] === '') {
|
||||
matcher.lastIndex += 1;
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function extractUrls(text: string): DetectedUrl[] {
|
||||
const detected: DetectedUrl[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of allMatches(URL_PATTERN, text)) {
|
||||
const raw = match[0].replace(TRAILING_PUNCTUATION, '');
|
||||
if (seen.has(raw)) {
|
||||
continue;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
seen.add(raw);
|
||||
detected.push({ raw, parsed, role: classifyUrl(raw, parsed) });
|
||||
if (detected.length >= MAX_URLS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
|
||||
function classifyUrl(raw: string, parsed: URL): UrlRole {
|
||||
if (M3U_PATH_PATTERN.test(parsed.pathname)) {
|
||||
return 'm3u';
|
||||
}
|
||||
if (XTREAM_API_PATH_PATTERN.test(parsed.pathname)) {
|
||||
return 'xtream-api';
|
||||
}
|
||||
if (
|
||||
isFullStalkerPortalUrl(raw) ||
|
||||
STALKER_PATH_PATTERN.test(parsed.pathname)
|
||||
) {
|
||||
return 'stalker';
|
||||
}
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
export function extractLabeledFields(
|
||||
text: string,
|
||||
urls: DetectedUrl[]
|
||||
): LabeledFields {
|
||||
// Query strings ARE label-shaped (`?username=u&password=p`), so labeled
|
||||
// extraction must never look inside a URL: credentials that ride in a
|
||||
// query are mined by `extractXtreamCredentialsFromUrl` with proper URL
|
||||
// decoding, and reading them as plain labels would truncate at `&`. Every
|
||||
// matcher therefore runs against a copy with URL spans blanked out — with
|
||||
// one exception: the host/server label legitimately points AT a URL
|
||||
// ("Portal: http://…"), so it reads the raw text, accepting only matches
|
||||
// whose label starts outside every URL span (`&url=…` inside a query
|
||||
// stays rejected).
|
||||
const spans = urlSpans(text, urls);
|
||||
const masked = maskSpans(text, spans);
|
||||
const fields: LabeledFields = {};
|
||||
for (const { field, pattern, validate } of FIELD_MATCHERS) {
|
||||
if (fields[field] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
const match = pattern.exec(field === 'host' ? text : masked);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (field === 'host' && isInsideSpan(spans, match.index)) {
|
||||
continue;
|
||||
}
|
||||
const value = stripWrapping(match[1] ?? '');
|
||||
if (!value || (validate && !validate(value))) {
|
||||
continue;
|
||||
}
|
||||
fields[field] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function extractMacAddresses(
|
||||
text: string,
|
||||
labeledMac: string | undefined
|
||||
): string[] {
|
||||
const macs: string[] = [];
|
||||
const push = (value: string | null) => {
|
||||
if (value && !macs.includes(value)) {
|
||||
macs.push(value);
|
||||
}
|
||||
};
|
||||
push(normalizeStalkerMacAddress(labeledMac));
|
||||
for (const match of allMatches(SEPARATED_MAC_PATTERN, text)) {
|
||||
push(normalizeStalkerMacAddress(match[0]));
|
||||
}
|
||||
for (const match of allMatches(BARE_INFOMIR_MAC_PATTERN, text)) {
|
||||
push(normalizeStalkerMacAddress(match[0]));
|
||||
}
|
||||
return macs.slice(0, MAX_MACS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a labeled server/host value into an http URL. A scheme-less host is
|
||||
* completed with `http://` and a separately labeled port — the one place a
|
||||
* value is assembled rather than found verbatim, and it lands in an editable
|
||||
* form field, never on the wire directly.
|
||||
*/
|
||||
export function labeledHostUrl(labeled: LabeledFields): string | undefined {
|
||||
const host = labeled.host;
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^https?:\/\//i.test(host)) {
|
||||
return host;
|
||||
}
|
||||
if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) {
|
||||
return undefined;
|
||||
}
|
||||
const portSuffix =
|
||||
host.includes(':') || !labeled.port ? '' : `:${labeled.port}`;
|
||||
return `http://${host}${portSuffix}`;
|
||||
}
|
||||
|
||||
function urlSpans(
|
||||
text: string,
|
||||
urls: DetectedUrl[]
|
||||
): Array<[number, number]> {
|
||||
const spans: Array<[number, number]> = [];
|
||||
for (const url of urls) {
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const index = text.indexOf(url.raw, from);
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
spans.push([index, index + url.raw.length]);
|
||||
from = index + url.raw.length;
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
function maskSpans(text: string, spans: Array<[number, number]>): string {
|
||||
if (spans.length === 0) {
|
||||
return text;
|
||||
}
|
||||
const chars = text.split('');
|
||||
for (const [start, end] of spans) {
|
||||
for (let index = start; index < end; index += 1) {
|
||||
chars[index] = ' ';
|
||||
}
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function isInsideSpan(
|
||||
spans: Array<[number, number]>,
|
||||
index: number
|
||||
): boolean {
|
||||
return spans.some(([start, end]) => index > start && index < end);
|
||||
}
|
||||
|
||||
function stripWrapping(value: string): string {
|
||||
return value.trim().replace(WRAPPING_CHARS, '').trim();
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export type WorkspacePlaylistType =
|
||||
| 'url'
|
||||
| 'text'
|
||||
| 'file'
|
||||
| 'stalker';
|
||||
| 'stalker'
|
||||
| 'auto';
|
||||
export type PlaylistCategory = 'm3u' | 'xtream' | 'stalker';
|
||||
export type M3uSubType = 'url' | 'file' | 'text';
|
||||
|
||||
|
||||
Reference in new issue
Block a user