diff --git a/.changes/stalker-identity-hardening.md b/.changes/stalker-identity-hardening.md
new file mode 100644
index 000000000..2d7e16ee8
--- /dev/null
+++ b/.changes/stalker-identity-hardening.md
@@ -0,0 +1,11 @@
+---
+type: feature
+area: stalker
+issues: [927, 860]
+---
+
+Stalker portals now check the MAC address as you type it and fix hyphens or
+lowercase for you, with a warning when it sits outside the range most portals
+accept. Device IDs can optionally be generated from the MAC the way StbEmu does,
+and a portal that already has a different device ID on file finally says so
+instead of reporting damaged hardware.
diff --git a/CLAUDE.md b/CLAUDE.md
index 39352c198..d7e0dfacb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1115,12 +1115,20 @@ engine` (restart required) or
- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`.
- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
-- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code.
+- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text.
- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections.
- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting.
- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle").
+**Stalker Identity Hardening**:
+
+- The MAC is canonicalized to `00:1A:79:XX:XX:XX` by `normalizeStalkerMacAddress` (`@iptvnator/shared/interfaces`) at the INPUT boundary only — the import dialog and the playlist-info edit dialog, on blur and again on submit. Stored MACs are never rewritten on read: the MAC is the account key, and a transport-level rewrite would move `stalkerSessionFingerprint` for every existing playlist with no user action. An edit does move it, deliberately. `validateStalkerMacAddressControl` is the shared form validator, typed structurally so the contracts lib stays Angular-free.
+- Format is enforced, the Infomir OUI is **advisory only**: `hasInfomirMacOui` drives a hint, never a rejection. The stock filter is off on most reseller panels, so non-Infomir MACs are working setups; refusing one would lock those users out (`AUTH_REJECTED_MAC` in `stalker.e2e.ts` relies on a non-Infomir MAC being importable, and the mock only applies `enforceMacFormat` on the strict endpoint). The edit dialog additionally grandfathers the stored value via `createStalkerMacAddressValidator` — a pre-validation playlist may hold arbitrary text, and blocking Save would strand its title/URL/EPG edits too.
+- `deriveStalkerDeviceIdsFromMac` returns the StbEmu / `stalker-to-m3u` PAIR: `SHA256(MAC)` for `device_id` and `SHA256(MAC + 'stalker')` for `device_id2`. They must differ — a real box reports them from separate firmware calls and never equal, and the pinning is permanent, so an identical pair could never be corrected. Offered as an opt-in checkbox **at import only**, writing into the visible fields and persisted as literal strings — never recomputed at request time. The portal pins the first non-empty `device_id`/`device_id2` to the MAC forever, refuses a different one, and treats a later empty value as a permanent lockout, so a derived value that silently followed a MAC edit would be unrecoverable. The edit dialog offers no derivation and shows `DEVICE_ID_PINNED_WARNING` once an ID is stored.
+- `get_profile` reports one coherent MAG250 via `STALKER_STB_PROFILE_PARAMS` (`ver`, `stb_type` — previously empty —, `hw_version`, `image_version`, `client_type`, `num_banks`, `video_out`, `hd`). Constants, identical per playlist, deliberately outside both fingerprints.
+- Contract: `docs/architecture/stalker-portal.md` ("Stalker Identity Policy").
+
**Favorites and Recently Viewed**:
- Per-playlist favorites and global favorites
diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts
index 15d236808..9d610621c 100644
--- a/apps/web-e2e/src/stalker.e2e.ts
+++ b/apps/web-e2e/src/stalker.e2e.ts
@@ -127,6 +127,14 @@ const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04';
*/
const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
+/**
+ * Its own MAC because the test PINS a device id on the portal, and a pin is
+ * the one piece of mock state that outlives an invalidated session (a real
+ * portal never unpins `device_id` either). `beforeEach` clears it through
+ * `OWNED_MACS`, which drops the whole session record including the pin.
+ */
+const DEVICE_CONFLICT_MAC = '00:1A:79:00:00:0B';
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -173,6 +181,7 @@ const OWNED_MACS = [
AUTH_FLOW_MAC,
AUTH_REAUTH_MAC,
AUTH_REJECTED_MAC,
+ DEVICE_CONFLICT_MAC,
];
/**
@@ -1263,6 +1272,64 @@ test.describe('@stalker full portal authentication', () => {
);
});
+ test('explains a device conflict instead of relaying "STB is damaged"', async ({
+ page,
+ request,
+ }) => {
+ // Pin a device id the way another client (StbEmu, a set-top box)
+ // would have: the stock server binds the first non-empty `device_id`
+ // it sees to the MAC and refuses every different one afterwards.
+ const pinned = await (
+ await request.get(
+ `${BACKEND_PROXY}?url=${encodeURIComponent(
+ FULL_PORTAL_URL
+ )}&macAddress=${encodeURIComponent(
+ DEVICE_CONFLICT_MAC
+ )}&action=get_profile&type=stb&device_id=PINNED-DEVICE-A`
+ )
+ ).json();
+ // Guard against a vacuous test: if the pin did not take, the import
+ // below would fail for some other reason and still show an error.
+ expect(pinned.js?.msg).toBeUndefined();
+
+ await page.getByRole('button', { name: 'Add playlist' }).click();
+ const dialog = page.locator('mat-dialog-container');
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
+
+ await setInputValue(dialog.locator('input#title'), 'Conflict Portal');
+ await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
+ await setInputValue(
+ dialog.locator('input#macAddress'),
+ DEVICE_CONFLICT_MAC
+ );
+ await setInputValue(
+ dialog.locator('input#deviceId1'),
+ 'DIFFERENT-DEVICE-B'
+ );
+
+ const addButton = dialog.getByRole('button', {
+ name: 'Add',
+ exact: true,
+ });
+ await expect(addButton).toBeEnabled({ timeout: 10_000 });
+ await addButton.click();
+
+ // The conflict gets its own headline. Asserting the generic one is
+ // ABSENT is what makes this test fail if the classification is
+ // removed — `blocked` would still surface the portal's text.
+ await expect(
+ page.getByText(/different device ID registered/i)
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText(/refused access/i)).toHaveCount(0);
+ // The portal's own words still travel with it, markup stripped.
+ await expect(page.getByText(/device_id mismatch/i)).toBeVisible();
+ await expect(page.locator('body')).not.toContainText(' ');
+
+ await expect(dialog).toBeVisible();
+ await expect(page).not.toHaveURL(/stalker/);
+ });
+
test('re-authenticates after the portal drops the session', async ({
page,
request,
diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index d583836f4..01b47a609 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "العنوان",
"MAC_ADDRESS": "عنوان MAC",
+ "MAC_ADDRESS_HINT": "التنسيق: 00:1A:79:XX:XX:XX — تُصحَّح الشرطات والنقاط والأحرف الصغيرة تلقائيًا.",
+ "MAC_ADDRESS_OUI_HINT": "هذا العنوان خارج النطاق 00:1A:79 الخاص بشركة Infomir. ومعظم البوابات لا تقبل سوى هذا النطاق وترفض ما عداه دون بيان السبب.",
+ "MAC_ADDRESS_ERROR": "أدخل عنوان MAC مكوَّنًا من 12 حرفًا سداسيًا عشريًا، مثال: 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "الرقم التسلسلي (اختياري)",
"SERIAL_NUMBER_HINT": "استخدمه إذا كنت مسجلاً بالفعل لدى المزود",
"DEVICE_ID_1": "معرّف الجهاز 1 (اختياري)",
"DEVICE_ID_2": "معرّف الجهاز 2 (اختياري)",
"DEVICE_ID_HINT": "64 حرفًا سداسيًا عشريًا - استخدمه إذا كان المزود يتطلب معرّف جهاز محدد",
+ "DERIVE_DEVICE_IDS": "توليد معرّفات الجهاز من عنوان MAC",
+ "DERIVE_DEVICE_IDS_HINT": "ينتج القيمة نفسها التي ينتجها StbEmu، أي تجزئة SHA-256 لعنوان MAC. تتمسك البوابة بأول معرّف جهاز تستقبله وترفض كل معرّف بعده، لذا لا تفعّل هذا الخيار إلا مع عنوان MAC لم تره البوابة من قبل، أو عنوان تستخدمه بالفعل مع معرّف مولَّد بواسطة StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "سجّلت البوابة بالفعل معرّف جهاز لعنوان MAC هذا. وتغييره أو مسحه سيمنع الوصول إلى هذا المصدر، لأن البوابة لا تقبل سوى معرّف الجهاز الذي رأته أولًا.",
"SIGNATURE_1": "التوقيع 1 (اختياري)",
"SIGNATURE_2": "التوقيع 2 (اختياري)",
"SIGNATURE_HINT": "64 حرفًا سداسيًا عشريًا - استخدمه إذا كان المزود يتطلب توقيعات للتحقق من الجهاز",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.",
"LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.",
+ "DEVICE_CONFLICT": "لدى البوابة معرّف جهاز مختلف مسجَّل لعنوان MAC هذا.",
"PORTAL_MESSAGE": "أفادت البوابة: {{message}}"
},
"FILTER_BY_NAME": "التصفية حسب الاسم",
@@ -964,6 +971,7 @@
"DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى"
},
"STALKER_LOGIN_REQUIRED": "تتطلب البوابة اسم مستخدم وكلمة مرور. أعد استيراد البوابة واملأ حقلي اسم المستخدم وكلمة المرور.",
+ "STALKER_DEVICE_CONFLICT": "لدى البوابة معرّف جهاز مختلف مسجَّل لعنوان MAC هذا. أعِد معرّف الجهاز الذي استخدمته أولًا، أو اطلب من المزود إعادة تعيين الجهاز المسجَّل لهذا العنوان.",
"PLAYLIST_SETTINGS": "إعدادات القائمة",
"HOME": "الرئيسية",
"DELETE": "حذف"
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 961d20ad3..0cca90eee 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "العنوان",
"MAC_ADDRESS": "عنوان MAC",
+ "MAC_ADDRESS_HINT": "الصيغة 00:1A:79:XX:XX:XX. الشرطات والنقط والحروف الصغيرة كيتصلحو ليك أوتوماتيكياً.",
+ "MAC_ADDRESS_OUI_HINT": "هاد العنوان خارج المجال 00:1A:79 ديال Infomir. أغلب البوابات كتقبل غير هاد المجال وكترفض أي شي آخر بلا ما تقول علاش.",
+ "MAC_ADDRESS_ERROR": "دخل عنوان MAC فيه 12 حرف hex، مثلا 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "الرقم التسلسلي (اختياري)",
"SERIAL_NUMBER_HINT": "استعملو إذا عندك تسجيل سابق مع المزود",
"DEVICE_ID_1": "معرف الجهاز 1 (اختياري)",
"DEVICE_ID_2": "معرف الجهاز 2 (اختياري)",
"DEVICE_ID_HINT": "64 حرف hex - استعملو إذا المزود كيتطلب معرف جهاز محدد",
+ "DERIVE_DEVICE_IDS": "ولّد معرفات الجهاز من عنوان MAC",
+ "DERIVE_DEVICE_IDS_HINT": "كيعطي نفس القيمة ديال StbEmu (SHA-256 ديال عنوان MAC). البوابة كتقفل على أول معرف جهاز كيوصلها وكترفض كل واحد اللي كيجي من بعدو، على هاد الشي فعّل هاد الخيار غير مع عنوان MAC البوابة عمرها ما شافتو، ولا واحد اللي كتستعمل معاه معرف مولّد من StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "البوابة سجلات من قبل معرف جهاز لهاد عنوان MAC. إلى بدّلتيه ولا مسحتيه غادي يتسد عليك هاد المصدر، حيت البوابة كتقبل غير معرف الجهاز اللي شافت الأول.",
"SIGNATURE_1": "التوقيع 1 (اختياري)",
"SIGNATURE_2": "التوقيع 2 (اختياري)",
"SIGNATURE_HINT": "64 حرف hex - استعملو إذا المزود كيتطلب توقيعات التحقق من الجهاز",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.",
"LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.",
+ "DEVICE_CONFLICT": "البوابة عندها معرف جهاز آخر مسجل لهاد عنوان MAC.",
"PORTAL_MESSAGE": "البوابة قالت: {{message}}"
},
"FILTER_BY_NAME": "فلتر بالاسم",
@@ -964,6 +971,7 @@
"DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى"
},
"STALKER_LOGIN_REQUIRED": "البوابة كتطلب اسم المستخدم وكلمة المرور. عاود استيراد البوابة وعمّر خانات اسم المستخدم وكلمة المرور.",
+ "STALKER_DEVICE_CONFLICT": "البوابة عندها معرف جهاز آخر مسجل لهاد عنوان MAC. رجّع معرف الجهاز اللي استعملتي الأول، ولا سول المزود ديالك باش يمسح تسجيل الجهاز لهاد MAC.",
"PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل",
"HOME": "الرئيسية",
"DELETE": "حذف"
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 97630f1e7..3f46c4965 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Назва",
"MAC_ADDRESS": "MAC-адрас",
+ "MAC_ADDRESS_HINT": "Фармат 00:1A:79:XX:XX:XX. Злучкі, кропкі і малыя літары выпраўляюцца аўтаматычна.",
+ "MAC_ADDRESS_OUI_HINT": "Гэты адрас не належыць да дыяпазону 00:1A:79 кампаніі Infomir. Большасць парталаў прымае толькі гэты дыяпазон і адхіляе ўсе астатнія адрасы без тлумачэння прычыны.",
+ "MAC_ADDRESS_ERROR": "Увядзіце MAC-адрас з 12 шаснаццатковых сімвалаў, напрыклад 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Серыйны нумар (неабавязкова)",
"SERIAL_NUMBER_HINT": "Выкарыстоўвайце, калі ўжо зарэгістраваны ў правайдэра",
"DEVICE_ID_1": "Device ID 1 (неабавязкова)",
"DEVICE_ID_2": "Device ID 2 (неабавязкова)",
"DEVICE_ID_HINT": "64 шаснаццатковыя сімвалы — выкарыстоўвайце, калі правайдэр патрабуе пэўны Device ID",
+ "DERIVE_DEVICE_IDS": "Генераваць Device ID з MAC-адраса",
+ "DERIVE_DEVICE_IDS_HINT": "Дае тое ж значэнне, што і StbEmu (SHA-256 ад MAC-адраса). Партал запамінае першы атрыманы Device ID і адхіляе ўсе наступныя, таму ўключайце гэта толькі для MAC-адраса, які партал яшчэ ніколі не бачыў, або для таго, які вы ўжо выкарыстоўваеце са згенераваным у StbEmu Device ID.",
+ "DEVICE_ID_PINNED_WARNING": "Партал ужо запісаў Device ID для гэтага MAC-адраса. Калі змяніць або ачысціць яго, доступ да гэтай крыніцы будзе страчаны, бо партал прымае толькі той Device ID, які ўбачыў першым.",
"SIGNATURE_1": "Подпіс 1 (неабавязкова)",
"SIGNATURE_2": "Подпіс 2 (неабавязкова)",
"SIGNATURE_HINT": "64 шаснаццатковыя сімвалы — выкарыстоўвайце, калі правайдэр патрабуе подпісы для верыфікацыі прылады",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.",
"LOGIN_REJECTED": "Партал адхіліў лагін і пароль.",
"PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.",
+ "DEVICE_CONFLICT": "Для гэтага MAC-адраса на партале зарэгістраваны іншы Device ID.",
"PORTAL_MESSAGE": "Партал паведаміў: {{message}}"
},
"FILTER_BY_NAME": "Фільтраваць па імені",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Калі ласка, выберыце катэгорыю, каб убачыць кантэнт"
},
"STALKER_LOGIN_REQUIRED": "Партал патрабуе лагін і пароль. Імпартуйце партал яшчэ раз і запоўніце палі імя карыстальніка і пароля.",
+ "STALKER_DEVICE_CONFLICT": "Для гэтага MAC-адраса на партале зарэгістраваны іншы Device ID. Вярніце Device ID, які вы выкарыстоўвалі першым, або папрасіце правайдэра скінуць прыладу для гэтага MAC-адраса.",
"PLAYLIST_SETTINGS": "Налады плэйліста",
"HOME": "На галоўную",
"DELETE": "Выдаліць"
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index f4989975e..4eeeb4e66 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Titel",
"MAC_ADDRESS": "Mac-Adresse",
+ "MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Bindestriche, Punkte und Kleinbuchstaben werden automatisch korrigiert.",
+ "MAC_ADDRESS_OUI_HINT": "Diese Adresse liegt außerhalb des Infomir-Bereichs 00:1A:79. Die meisten Portale akzeptieren nur diesen Bereich und lehnen alles andere ohne Begründung ab.",
+ "MAC_ADDRESS_ERROR": "Geben Sie eine 12-stellige MAC-Adresse ein, zum Beispiel 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Seriennummer (optional)",
"SERIAL_NUMBER_HINT": "Verwenden, wenn bereits beim Anbieter registriert",
"DEVICE_ID_1": "Geräte-ID 1 (optional)",
"DEVICE_ID_2": "Geräte-ID 2 (optional)",
"DEVICE_ID_HINT": "64 Hex-Zeichen – verwenden, wenn der Anbieter eine bestimmte Geräte-ID verlangt",
+ "DERIVE_DEVICE_IDS": "Geräte-IDs aus der MAC-Adresse erzeugen",
+ "DERIVE_DEVICE_IDS_HINT": "Erzeugt denselben Wert wie StbEmu (SHA-256 der MAC-Adresse). Das Portal merkt sich dauerhaft die erste Geräte-ID, die es erhält, und lehnt jede spätere ab. Aktivieren Sie die Option deshalb nur für eine MAC-Adresse, die das Portal noch nie gesehen hat, oder für eine, die Sie bereits mit einer von StbEmu erzeugten ID verwenden.",
+ "DEVICE_ID_PINNED_WARNING": "Das Portal hat für diese MAC-Adresse bereits eine Geräte-ID gespeichert. Wenn Sie sie ändern oder löschen, sperren Sie sich aus dieser Quelle aus, denn das Portal akzeptiert nur die Geräte-ID, die es zuerst gesehen hat.",
"SIGNATURE_1": "Signatur 1 (optional)",
"SIGNATURE_2": "Signatur 2 (optional)",
"SIGNATURE_HINT": "64 Hex-Zeichen – verwenden, wenn der Anbieter Geräte-Verifizierungssignaturen verlangt",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.",
"LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.",
"PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.",
+ "DEVICE_CONFLICT": "Für diese MAC-Adresse ist im Portal eine andere Geräte-ID registriert.",
"PORTAL_MESSAGE": "Das Portal meldet: {{message}}"
},
"FILTER_BY_NAME": "Nach Namen filtern",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen"
},
"STALKER_LOGIN_REQUIRED": "Das Portal erfordert Benutzername und Passwort. Importieren Sie das Portal erneut und füllen Sie die Felder Benutzername und Passwort aus.",
+ "STALKER_DEVICE_CONFLICT": "Für diese MAC-Adresse ist im Portal eine andere Geräte-ID registriert. Stellen Sie die zuerst verwendete Geräte-ID wieder her oder bitten Sie Ihren Anbieter, das für diese MAC-Adresse registrierte Gerät zurückzusetzen.",
"PLAYLIST_SETTINGS": "Playlist-Einstellungen",
"HOME": "Startseite",
"DELETE": "Löschen"
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index 0f4ca122b..ad300b364 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Τίτλος",
"MAC_ADDRESS": "Mac Address",
+ "MAC_ADDRESS_HINT": "Μορφή 00:1A:79:XX:XX:XX. Οι παύλες, οι τελείες και τα πεζά γράμματα διορθώνονται αυτόματα.",
+ "MAC_ADDRESS_OUI_HINT": "Αυτή η διεύθυνση είναι εκτός του εύρους 00:1A:79 της Infomir. Οι περισσότερες πύλες δέχονται μόνο αυτό το εύρος και απορρίπτουν οτιδήποτε άλλο χωρίς να εξηγούν τον λόγο.",
+ "MAC_ADDRESS_ERROR": "Εισαγάγετε μια διεύθυνση MAC 12 δεκαεξαδικών ψηφίων, για παράδειγμα 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Σειριακός αριθμός (Προαιρετικό)",
"SERIAL_NUMBER_HINT": "Χρησιμοποιήστε εάν είστε ήδη εγγεγραμμένος στον πάροχο",
"DEVICE_ID_1": "Αναγνωριστικό συσκευής 1 (Προαιρετικό)",
"DEVICE_ID_2": "Αναγνωριστικό συσκευής 2 (Προαιρετικό)",
"DEVICE_ID_HINT": "64 δεκαεξαδικοί χαρακτήρες - χρησιμοποιήστε εάν ο πάροχος απαιτεί συγκεκριμένο αναγνωριστικό συσκευής",
+ "DERIVE_DEVICE_IDS": "Δημιουργία αναγνωριστικών συσκευής από τη διεύθυνση MAC",
+ "DERIVE_DEVICE_IDS_HINT": "Παράγει την ίδια τιμή με το StbEmu (SHA-256 της διεύθυνσης MAC). Η πύλη δεσμεύεται μόνιμα στο πρώτο αναγνωριστικό συσκευής που λαμβάνει και απορρίπτει κάθε επόμενο, γι' αυτό ενεργοποιήστε το μόνο για μια διεύθυνση MAC που δεν έχει δει ποτέ η πύλη ή για μια διεύθυνση που χρησιμοποιείτε ήδη με αναγνωριστικό δημιουργημένο από το StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "Η πύλη έχει ήδη καταγράψει ένα αναγνωριστικό συσκευής για αυτήν τη διεύθυνση MAC. Αν το αλλάξετε ή το διαγράψετε, θα χάσετε την πρόσβαση σε αυτήν την πηγή, επειδή η πύλη δέχεται μόνο το αναγνωριστικό συσκευής που είδε πρώτο.",
"SIGNATURE_1": "Υπογραφή 1 (Προαιρετικό)",
"SIGNATURE_2": "Υπογραφή 2 (Προαιρετικό)",
"SIGNATURE_HINT": "64 δεκαεξαδικοί χαρακτήρες - χρησιμοποιήστε εάν ο πάροχος απαιτεί υπογραφές επαλήθευσης συσκευής",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.",
"LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.",
"PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
+ "DEVICE_CONFLICT": "Η πύλη έχει καταχωρημένο διαφορετικό αναγνωριστικό συσκευής για αυτήν τη διεύθυνση MAC.",
"PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}"
},
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Επιλέξτε μια κατηγορία για να δείτε το περιεχόμενο"
},
"STALKER_LOGIN_REQUIRED": "Η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Εισαγάγετε ξανά την πύλη και συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης.",
+ "STALKER_DEVICE_CONFLICT": "Η πύλη έχει καταχωρημένο διαφορετικό αναγνωριστικό συσκευής για αυτήν τη διεύθυνση MAC. Επαναφέρετε το αναγνωριστικό συσκευής που χρησιμοποιήσατε αρχικά ή ζητήστε από τον πάροχό σας να επαναφέρει τη συσκευή για αυτήν τη διεύθυνση MAC.",
"PLAYLIST_SETTINGS": "Ρυθμίσεις λίστας αναπαραγωγής",
"HOME": "Αρχικό",
"DELETE": "Διαγραφή"
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index a86135fd8..e2baf15a4 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Title",
"MAC_ADDRESS": "Mac Address",
+ "MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Hyphens, dots and lowercase are corrected for you.",
+ "MAC_ADDRESS_OUI_HINT": "This address is outside Infomir's 00:1A:79 range. Most portals only accept that range and reject anything else without saying why.",
+ "MAC_ADDRESS_ERROR": "Enter a 12-digit MAC address, for example 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Serial Number (Optional)",
"SERIAL_NUMBER_HINT": "Use if already registered with provider",
"DEVICE_ID_1": "Device ID 1 (Optional)",
"DEVICE_ID_2": "Device ID 2 (Optional)",
"DEVICE_ID_HINT": "64 hex characters - use if provider requires specific device ID",
+ "DERIVE_DEVICE_IDS": "Generate device IDs from the MAC address",
+ "DERIVE_DEVICE_IDS_HINT": "Produces the same value as StbEmu (SHA-256 of the MAC). The portal locks on to the first device ID it receives and refuses every later one, so only turn this on for a MAC the portal has never seen, or one you already use with a StbEmu-generated ID.",
+ "DEVICE_ID_PINNED_WARNING": "The portal has already recorded a device ID for this MAC address. Changing or clearing it will lock this source out, because the portal only accepts the device ID it saw first.",
"SIGNATURE_1": "Signature 1 (Optional)",
"SIGNATURE_2": "Signature 2 (Optional)",
"SIGNATURE_HINT": "64 hex characters - use if provider requires device verification signatures",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.",
"LOGIN_REJECTED": "The portal rejected the login and password.",
"PORTAL_REFUSED": "The portal refused access for this device.",
+ "DEVICE_CONFLICT": "The portal has a different device ID registered for this MAC address.",
"PORTAL_MESSAGE": "The portal reported: {{message}}"
},
"FILTER_BY_NAME": "Filter by name",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Please select a category to see the content"
},
"STALKER_LOGIN_REQUIRED": "The portal requires a login and password. Import the portal again and fill in the username and password fields.",
+ "STALKER_DEVICE_CONFLICT": "The portal has a different device ID registered for this MAC address. Restore the device ID you used first, or ask your provider to reset the device for this MAC.",
"PLAYLIST_SETTINGS": "Playlist Settings",
"HOME": "Home",
"DELETE": "Delete"
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index c936ccc3e..af6718930 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Título",
"MAC_ADDRESS": "Dirección Mac",
+ "MAC_ADDRESS_HINT": "Formato 00:1A:79:XX:XX:XX. Los guiones, los puntos y las minúsculas se corrigen automáticamente.",
+ "MAC_ADDRESS_OUI_HINT": "Esta dirección está fuera del rango 00:1A:79 de Infomir. La mayoría de los portales solo aceptan ese rango y rechazan cualquier otro sin indicar el motivo.",
+ "MAC_ADDRESS_ERROR": "Ingresa una dirección MAC de 12 dígitos, por ejemplo 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Número de serie (opcional)",
"SERIAL_NUMBER_HINT": "Úsalo si ya estás registrado con el proveedor",
"DEVICE_ID_1": "ID de dispositivo 1 (opcional)",
"DEVICE_ID_2": "ID de dispositivo 2 (opcional)",
"DEVICE_ID_HINT": "64 caracteres hexadecimales — úsalo si el proveedor requiere un ID de dispositivo específico",
+ "DERIVE_DEVICE_IDS": "Generar los ID de dispositivo a partir de la dirección MAC",
+ "DERIVE_DEVICE_IDS_HINT": "Genera el mismo valor que StbEmu (SHA-256 de la dirección MAC). El portal se queda con el primer ID de dispositivo que recibe y rechaza todos los siguientes, así que activa esta opción solo para una MAC que el portal nunca haya visto o para una que ya uses con un ID generado por StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "El portal ya registró un ID de dispositivo para esta dirección MAC. Si lo cambias o lo borras, perderás el acceso a esta fuente, porque el portal solo acepta el ID de dispositivo que vio primero.",
"SIGNATURE_1": "Firma 1 (opcional)",
"SIGNATURE_2": "Firma 2 (opcional)",
"SIGNATURE_HINT": "64 caracteres hexadecimales — úsalo si el proveedor requiere firmas de verificación del dispositivo",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.",
"LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.",
"PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.",
+ "DEVICE_CONFLICT": "El portal tiene registrado un ID de dispositivo distinto para esta dirección MAC.",
"PORTAL_MESSAGE": "El portal informó: {{message}}"
},
"FILTER_BY_NAME": "Filtrar por nombre",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Selecciona una categoría para ver el contenido"
},
"STALKER_LOGIN_REQUIRED": "El portal requiere usuario y contraseña. Vuelve a importar el portal y completa los campos de nombre de usuario y contraseña.",
+ "STALKER_DEVICE_CONFLICT": "El portal tiene registrado un ID de dispositivo distinto para esta dirección MAC. Restaura el ID de dispositivo que usaste primero o pídele a tu proveedor que restablezca el dispositivo registrado para esta MAC.",
"PLAYLIST_SETTINGS": "Configuración de lista de reproducción",
"HOME": "Hogar",
"DELETE": "Borrar"
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index 9bc0421ef..888e5e739 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Titre",
"MAC_ADDRESS": "Adresse Mac",
+ "MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Les tirets, les points et les minuscules sont corrigés automatiquement.",
+ "MAC_ADDRESS_OUI_HINT": "Cette adresse ne fait pas partie de la plage 00:1A:79 d'Infomir. La plupart des portails n'acceptent que cette plage et rejettent toute autre adresse sans en indiquer la raison.",
+ "MAC_ADDRESS_ERROR": "Saisissez une adresse MAC de 12 caractères hexadécimaux, par exemple 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Numéro de série (optionnel)",
"SERIAL_NUMBER_HINT": "À utiliser si déjà enregistré auprès du fournisseur",
"DEVICE_ID_1": "ID d'appareil 1 (optionnel)",
"DEVICE_ID_2": "ID d'appareil 2 (optionnel)",
"DEVICE_ID_HINT": "64 caractères hexadécimaux — à utiliser si le fournisseur exige un ID d'appareil spécifique",
+ "DERIVE_DEVICE_IDS": "Générer les ID d'appareil à partir de l'adresse MAC",
+ "DERIVE_DEVICE_IDS_HINT": "Produit la même valeur que StbEmu (SHA-256 de l'adresse MAC). Le portail mémorise définitivement le premier ID d'appareil qu'il reçoit et refuse tous les suivants. N'activez donc cette option que pour une adresse MAC que le portail n'a jamais vue, ou pour une adresse MAC que vous utilisez déjà avec un ID généré par StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "Le portail a déjà enregistré un ID d'appareil pour cette adresse MAC. Le modifier ou l'effacer bloquera l'accès à cette source, car le portail n'accepte que l'ID d'appareil qu'il a vu en premier.",
"SIGNATURE_1": "Signature 1 (optionnelle)",
"SIGNATURE_2": "Signature 2 (optionnelle)",
"SIGNATURE_HINT": "64 caractères hexadécimaux — à utiliser si le fournisseur exige des signatures de vérification d'appareil",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.",
"LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.",
"PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.",
+ "DEVICE_CONFLICT": "Le portail a enregistré un autre ID d'appareil pour cette adresse MAC.",
"PORTAL_MESSAGE": "Le portail a signalé : {{message}}"
},
"FILTER_BY_NAME": "Filtrer par nom",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Veuillez sélectionner une catégorie pour voir le contenu"
},
"STALKER_LOGIN_REQUIRED": "Le portail nécessite un identifiant et un mot de passe. Importez à nouveau le portail et renseignez les champs nom d'utilisateur et mot de passe.",
+ "STALKER_DEVICE_CONFLICT": "Le portail a enregistré un autre ID d'appareil pour cette adresse MAC. Rétablissez l'ID d'appareil que vous avez utilisé en premier, ou demandez à votre fournisseur de réinitialiser l'appareil enregistré pour cette adresse MAC.",
"PLAYLIST_SETTINGS": "Paramètres de la liste",
"HOME": "Accueil",
"DELETE": "Supprimer"
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index ee09db2be..e0b62c911 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Cím",
"MAC_ADDRESS": "MAC-cím",
+ "MAC_ADDRESS_HINT": "Formátum: 00:1A:79:XX:XX:XX. A kötőjeleket, a pontokat és a kisbetűket az alkalmazás automatikusan kijavítja.",
+ "MAC_ADDRESS_OUI_HINT": "Ez a cím az Infomir 00:1A:79 tartományán kívül esik. A legtöbb portál csak ezt a tartományt fogadja el, minden mást indoklás nélkül elutasít.",
+ "MAC_ADDRESS_ERROR": "Adjon meg 12 jegyű MAC-címet, például 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Sorozatszám (nem kötelező)",
"SERIAL_NUMBER_HINT": "Akkor használja, ha az eszköz már regisztrálva van a szolgáltatónál",
"DEVICE_ID_1": "1. eszközazonosító (nem kötelező)",
"DEVICE_ID_2": "2. eszközazonosító (nem kötelező)",
"DEVICE_ID_HINT": "64 hexadecimális karakter – csak akkor adja meg, ha a szolgáltató meghatározott eszközazonosítót kér",
+ "DERIVE_DEVICE_IDS": "Eszközazonosítók előállítása a MAC-címből",
+ "DERIVE_DEVICE_IDS_HINT": "Ugyanazt az értéket állítja elő, mint a StbEmu (a MAC-cím SHA-256 lenyomata). A portál véglegesen megjegyzi az elsőként kapott eszközazonosítót, és minden későbbit elutasít, ezért csak olyan MAC-címhez kapcsolja be, amelyet a portál még soha nem látott, vagy amelyet már StbEmu által előállított azonosítóval használ.",
+ "DEVICE_ID_PINNED_WARNING": "A portál már rögzített egy eszközazonosítót ehhez a MAC-címhez. Ha megváltoztatja vagy törli, ez a forrás elérhetetlenné válik, mert a portál kizárólag az elsőként látott eszközazonosítót fogadja el.",
"SIGNATURE_1": "1. aláírás (nem kötelező)",
"SIGNATURE_2": "2. aláírás (nem kötelező)",
"SIGNATURE_HINT": "64 hexadecimális karakter – csak akkor adja meg, ha a szolgáltató eszközellenőrzési aláírást kér",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.",
"LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.",
"PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.",
+ "DEVICE_CONFLICT": "A portálon más eszközazonosító van regisztrálva ehhez a MAC-címhez.",
"PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}"
},
"FILTER_BY_NAME": "Szűrés név alapján",
@@ -964,6 +971,7 @@
"DESCRIPTION": "A tartalom megjelenítéséhez válasszon egy kategóriát."
},
"STALKER_LOGIN_REQUIRED": "A portál felhasználónevet és jelszót igényel. Importálja újra a portált, és töltse ki a felhasználónév és a jelszó mezőt.",
+ "STALKER_DEVICE_CONFLICT": "A portálon más eszközazonosító van regisztrálva ehhez a MAC-címhez. Állítsa vissza az elsőként használt eszközazonosítót, vagy kérje a szolgáltatótól az ehhez a MAC-címhez rögzített eszköz alaphelyzetbe állítását.",
"PLAYLIST_SETTINGS": "Lejátszási lista beállításai",
"HOME": "Kezdőlap",
"DELETE": "Törlés"
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index 5212c6bfe..72ab26444 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Titolo",
"MAC_ADDRESS": "Indirizzo MAC",
+ "MAC_ADDRESS_HINT": "Formato 00:1A:79:XX:XX:XX. Trattini, punti e lettere minuscole vengono corretti automaticamente.",
+ "MAC_ADDRESS_OUI_HINT": "Questo indirizzo non rientra nell'intervallo 00:1A:79 di Infomir. La maggior parte dei portali accetta solo quell'intervallo e rifiuta tutto il resto senza spiegarne il motivo.",
+ "MAC_ADDRESS_ERROR": "Inserisci un indirizzo MAC di 12 cifre esadecimali, ad esempio 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Numero di serie (opzionale)",
"SERIAL_NUMBER_HINT": "Usa se già registrato presso il fornitore",
"DEVICE_ID_1": "ID dispositivo 1 (opzionale)",
"DEVICE_ID_2": "ID dispositivo 2 (opzionale)",
"DEVICE_ID_HINT": "64 caratteri esadecimali — usa se il fornitore richiede un ID dispositivo specifico",
+ "DERIVE_DEVICE_IDS": "Genera gli ID dispositivo dall'indirizzo MAC",
+ "DERIVE_DEVICE_IDS_HINT": "Produce lo stesso valore di StbEmu (SHA-256 del MAC). Il portale registra in modo permanente il primo ID dispositivo che riceve e rifiuta tutti quelli successivi, quindi attiva questa opzione solo per un MAC che il portale non ha mai visto, oppure per uno che usi già con un ID generato da StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "Il portale ha già registrato un ID dispositivo per questo indirizzo MAC. Modificarlo o cancellarlo renderà inaccessibile questa sorgente, perché il portale accetta solo il primo ID dispositivo che ha ricevuto.",
"SIGNATURE_1": "Firma 1 (opzionale)",
"SIGNATURE_2": "Firma 2 (opzionale)",
"SIGNATURE_HINT": "64 caratteri esadecimali — usa se il fornitore richiede firme di verifica del dispositivo",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.",
"LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.",
"PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.",
+ "DEVICE_CONFLICT": "Il portale ha registrato un ID dispositivo diverso per questo indirizzo MAC.",
"PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}"
},
"FILTER_BY_NAME": "Filtra per nome",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Seleziona una categoria per vedere i contenuti"
},
"STALKER_LOGIN_REQUIRED": "Il portale richiede nome utente e password. Importa di nuovo il portale e compila i campi nome utente e password.",
+ "STALKER_DEVICE_CONFLICT": "Il portale ha registrato un ID dispositivo diverso per questo indirizzo MAC. Ripristina l'ID dispositivo che hai usato inizialmente oppure chiedi al tuo fornitore di reimpostare il dispositivo associato a questo MAC.",
"PLAYLIST_SETTINGS": "Impostazioni Playlist",
"HOME": "Home",
"DELETE": "Elimina"
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index 49e89011e..e968b7e1b 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "タイトル",
"MAC_ADDRESS": "MACアドレス",
+ "MAC_ADDRESS_HINT": "形式は00:1A:79:XX:XX:XXです。ハイフンやドット、小文字は自動的に修正されます。",
+ "MAC_ADDRESS_OUI_HINT": "このアドレスはInfomirの00:1A:79の範囲外です。ほとんどのポータルはこの範囲のみを受け付け、それ以外は理由を示さずに拒否します。",
+ "MAC_ADDRESS_ERROR": "16進12桁のMACアドレスを入力してください(例:00:1A:79:12:34:56)",
"SERIAL_NUMBER": "シリアル番号(任意)",
"SERIAL_NUMBER_HINT": "プロバイダーに登録済みの場合に使用",
"DEVICE_ID_1": "デバイスID 1(任意)",
"DEVICE_ID_2": "デバイスID 2(任意)",
"DEVICE_ID_HINT": "16進64文字。プロバイダーが特定のデバイスIDを要求する場合に使用",
+ "DERIVE_DEVICE_IDS": "MACアドレスからデバイスIDを生成する",
+ "DERIVE_DEVICE_IDS_HINT": "StbEmuと同じ値(MACアドレスのSHA-256)を生成します。ポータルは最初に受け取ったデバイスIDを記憶し、それ以降のデバイスIDはすべて拒否します。ポータルがまだ受け取ったことのないMACアドレス、またはStbEmuで生成したIDとすでに併用しているMACアドレスの場合にのみ有効にしてください。",
+ "DEVICE_ID_PINNED_WARNING": "このMACアドレスのデバイスIDは、すでにポータルに記録されています。ポータルは最初に受け取ったデバイスIDしか受け付けないため、変更したり消去したりするとこのソースにアクセスできなくなります。",
"SIGNATURE_1": "署名 1(任意)",
"SIGNATURE_2": "署名 2(任意)",
"SIGNATURE_HINT": "16進64文字。プロバイダーがデバイス検証署名を要求する場合に使用",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。",
"LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。",
"PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。",
+ "DEVICE_CONFLICT": "このMACアドレスには、別のデバイスIDがポータルに登録されています。",
"PORTAL_MESSAGE": "ポータルからの報告:{{message}}"
},
"FILTER_BY_NAME": "名前でフィルター",
@@ -964,6 +971,7 @@
"DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください"
},
"STALKER_LOGIN_REQUIRED": "ポータルにはユーザー名とパスワードが必要です。ポータルを再インポートして、ユーザー名とパスワードの欄を入力してください。",
+ "STALKER_DEVICE_CONFLICT": "このMACアドレスには、別のデバイスIDがポータルに登録されています。最初に使用したデバイスIDに戻すか、このMACアドレスに登録されているデバイスをリセットするようプロバイダーに依頼してください。",
"PLAYLIST_SETTINGS": "プレイリスト設定",
"HOME": "ホーム",
"DELETE": "削除"
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 999697dee..6691d2f39 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "제목",
"MAC_ADDRESS": "MAC 주소",
+ "MAC_ADDRESS_HINT": "형식: 00:1A:79:XX:XX:XX. 하이픈, 점, 소문자는 자동으로 수정됩니다.",
+ "MAC_ADDRESS_OUI_HINT": "이 주소는 Infomir의 00:1A:79 범위를 벗어납니다. 대부분의 포털은 해당 범위만 허용하며, 그 외의 주소는 이유를 알리지 않고 거부합니다.",
+ "MAC_ADDRESS_ERROR": "12자리 MAC 주소를 입력하세요(예: 00:1A:79:12:34:56).",
"SERIAL_NUMBER": "시리얼 번호 (선택 사항)",
"SERIAL_NUMBER_HINT": "공급자에 이미 등록된 경우 사용",
"DEVICE_ID_1": "기기 ID 1 (선택 사항)",
"DEVICE_ID_2": "기기 ID 2 (선택 사항)",
"DEVICE_ID_HINT": "16진수 64자 - 공급자가 특정 기기 ID를 요구할 때 사용",
+ "DERIVE_DEVICE_IDS": "MAC 주소에서 기기 ID 생성",
+ "DERIVE_DEVICE_IDS_HINT": "StbEmu와 동일한 값(MAC의 SHA-256)을 생성합니다. 포털은 처음 받은 기기 ID를 고정해 두고 이후에 오는 값은 모두 거부하므로, 포털이 한 번도 본 적 없는 MAC이거나 이미 StbEmu로 생성한 ID와 함께 사용 중인 MAC에만 이 옵션을 켜세요.",
+ "DEVICE_ID_PINNED_WARNING": "포털에 이 MAC 주소의 기기 ID가 이미 기록되어 있습니다. 포털은 처음 확인한 기기 ID만 허용하므로, 값을 변경하거나 지우면 이 소스에 접근할 수 없게 됩니다.",
"SIGNATURE_1": "서명 1 (선택 사항)",
"SIGNATURE_2": "서명 2 (선택 사항)",
"SIGNATURE_HINT": "16진수 64자 - 공급자가 기기 검증 서명을 요구할 때 사용",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.",
"LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.",
"PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.",
+ "DEVICE_CONFLICT": "포털에 이 MAC 주소로 다른 기기 ID가 등록되어 있습니다.",
"PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}"
},
"FILTER_BY_NAME": "이름으로 필터",
@@ -964,6 +971,7 @@
"DESCRIPTION": "콘텐츠를 보려면 카테고리를 선택하세요"
},
"STALKER_LOGIN_REQUIRED": "포털에 사용자 이름과 비밀번호가 필요합니다. 포털을 다시 가져온 후 사용자 이름과 비밀번호 필드를 입력하세요.",
+ "STALKER_DEVICE_CONFLICT": "포털에 이 MAC 주소로 다른 기기 ID가 등록되어 있습니다. 처음에 사용한 기기 ID를 복원하거나, 이 MAC에 등록된 기기를 초기화해 달라고 공급자에게 요청하세요.",
"PLAYLIST_SETTINGS": "재생목록 설정",
"HOME": "홈",
"DELETE": "삭제"
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index 0038dcc28..2093410be 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Titel",
"MAC_ADDRESS": "Mac adres",
+ "MAC_ADDRESS_HINT": "Formaat 00:1A:79:XX:XX:XX. Streepjes, punten en kleine letters worden automatisch voor je gecorrigeerd.",
+ "MAC_ADDRESS_OUI_HINT": "Dit adres valt buiten het 00:1A:79-bereik van Infomir. De meeste portalen accepteren alleen dat bereik en weigeren al het andere zonder uitleg.",
+ "MAC_ADDRESS_ERROR": "Voer een MAC-adres van 12 hex-tekens in, bijvoorbeeld 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Serienummer (optioneel)",
"SERIAL_NUMBER_HINT": "Gebruiken indien al geregistreerd bij de provider",
"DEVICE_ID_1": "Apparaat-ID 1 (optioneel)",
"DEVICE_ID_2": "Apparaat-ID 2 (optioneel)",
"DEVICE_ID_HINT": "64 hex-tekens — gebruiken als de provider een specifieke apparaat-ID vereist",
+ "DERIVE_DEVICE_IDS": "Apparaat-ID's genereren uit het MAC-adres",
+ "DERIVE_DEVICE_IDS_HINT": "Levert dezelfde waarde op als StbEmu (SHA-256 van het MAC-adres). Het portaal legt de eerste apparaat-ID die het ontvangt definitief vast en weigert elke volgende, dus schakel dit alleen in voor een MAC-adres dat het portaal nog nooit heeft gezien, of voor een MAC-adres dat je al gebruikt met een door StbEmu gegenereerde ID.",
+ "DEVICE_ID_PINNED_WARNING": "Het portaal heeft al een apparaat-ID vastgelegd voor dit MAC-adres. Als je die wijzigt of wist, is deze bron niet meer toegankelijk, omdat het portaal alleen de apparaat-ID accepteert die het als eerste heeft gezien.",
"SIGNATURE_1": "Handtekening 1 (optioneel)",
"SIGNATURE_2": "Handtekening 2 (optioneel)",
"SIGNATURE_HINT": "64 hex-tekens — gebruiken als de provider apparaatverificatiehandtekeningen vereist",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.",
"LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.",
"PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.",
+ "DEVICE_CONFLICT": "Het portaal heeft een andere apparaat-ID geregistreerd voor dit MAC-adres.",
"PORTAL_MESSAGE": "Het portaal meldde: {{message}}"
},
"FILTER_BY_NAME": "Filter op naam",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Selecteer een categorie om de inhoud te bekijken"
},
"STALKER_LOGIN_REQUIRED": "Het portaal vereist een gebruikersnaam en wachtwoord. Importeer het portaal opnieuw en vul de velden gebruikersnaam en wachtwoord in.",
+ "STALKER_DEVICE_CONFLICT": "Het portaal heeft een andere apparaat-ID geregistreerd voor dit MAC-adres. Herstel de apparaat-ID die je als eerste hebt gebruikt, of vraag je provider om het apparaat voor dit MAC-adres te resetten.",
"PLAYLIST_SETTINGS": "Afspeellijst instellingen",
"HOME": "Home",
"DELETE": "Verwijderen"
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index 40670643a..266c512bf 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Tytuł",
"MAC_ADDRESS": "Adres Mac",
+ "MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Myślniki, kropki i małe litery zostaną poprawione automatycznie.",
+ "MAC_ADDRESS_OUI_HINT": "Ten adres jest spoza zakresu 00:1A:79 firmy Infomir. Większość portali akceptuje tylko ten zakres i bez wyjaśnienia odrzuca wszystkie inne adresy.",
+ "MAC_ADDRESS_ERROR": "Wprowadź adres MAC złożony z 12 znaków szesnastkowych, na przykład 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Numer seryjny (opcjonalnie)",
"SERIAL_NUMBER_HINT": "Użyj, jeśli konto jest już zarejestrowane u dostawcy",
"DEVICE_ID_1": "ID urządzenia 1 (opcjonalnie)",
"DEVICE_ID_2": "ID urządzenia 2 (opcjonalnie)",
"DEVICE_ID_HINT": "64 znaki szesnastkowe – użyj, jeśli dostawca wymaga konkretnego ID urządzenia",
+ "DERIVE_DEVICE_IDS": "Generowanie ID urządzenia na podstawie adresu MAC",
+ "DERIVE_DEVICE_IDS_HINT": "Daje tę samą wartość co StbEmu (SHA-256 adresu MAC). Portal zapamiętuje na stałe pierwsze otrzymane ID urządzenia i odrzuca każde kolejne, dlatego włącz tę opcję tylko dla adresu MAC, którego portal jeszcze nie widział, albo takiego, który jest już używany z ID wygenerowanym przez StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "Portal zapisał już ID urządzenia dla tego adresu MAC. Zmiana lub usunięcie tej wartości zablokuje dostęp do tego źródła, ponieważ portal akceptuje wyłącznie ID urządzenia, które otrzymał jako pierwsze.",
"SIGNATURE_1": "Sygnatura 1 (opcjonalnie)",
"SIGNATURE_2": "Sygnatura 2 (opcjonalnie)",
"SIGNATURE_HINT": "64 znaki szesnastkowe – użyj, jeśli dostawca wymaga sygnatur weryfikacji urządzenia",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.",
"LOGIN_REJECTED": "Portal odrzucił login i hasło.",
"PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.",
+ "DEVICE_CONFLICT": "Portal ma zarejestrowane inne ID urządzenia dla tego adresu MAC.",
"PORTAL_MESSAGE": "Portal zgłosił: {{message}}"
},
"FILTER_BY_NAME": "Filtruj według nazwy",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Wybierz kategorię, aby zobaczyć treści"
},
"STALKER_LOGIN_REQUIRED": "Portal wymaga loginu i hasła. Zaimportuj portal ponownie i wypełnij pola nazwy użytkownika i hasła.",
+ "STALKER_DEVICE_CONFLICT": "Portal ma zarejestrowane inne ID urządzenia dla tego adresu MAC. Przywróć pierwotnie użyte ID urządzenia lub poproś dostawcę o zresetowanie urządzenia przypisanego do tego adresu MAC.",
"PLAYLIST_SETTINGS": "Ustawienia listy odtwarzania",
"HOME": "Strona główna",
"DELETE": "Usuń"
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index 376dcd436..2248deb26 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Título",
"MAC_ADDRESS": "Endereço Mac",
+ "MAC_ADDRESS_HINT": "Formato 00:1A:79:XX:XX:XX. Hífens, pontos e letras minúsculas são corrigidos automaticamente.",
+ "MAC_ADDRESS_OUI_HINT": "Este endereço está fora da faixa 00:1A:79 da Infomir. A maioria dos portais aceita apenas essa faixa e rejeita qualquer outra sem informar o motivo.",
+ "MAC_ADDRESS_ERROR": "Digite um endereço MAC de 12 dígitos, por exemplo 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Número de série (opcional)",
"SERIAL_NUMBER_HINT": "Use se já estiver registrado com o provedor",
"DEVICE_ID_1": "ID do dispositivo 1 (opcional)",
"DEVICE_ID_2": "ID do dispositivo 2 (opcional)",
"DEVICE_ID_HINT": "64 caracteres hexadecimais - use se o provedor exigir um ID de dispositivo específico",
+ "DERIVE_DEVICE_IDS": "Gerar os IDs do dispositivo a partir do endereço MAC",
+ "DERIVE_DEVICE_IDS_HINT": "Gera o mesmo valor que o StbEmu (SHA-256 do MAC). O portal fixa o primeiro ID de dispositivo que recebe e recusa todos os posteriores, portanto ative esta opção apenas para um MAC que o portal nunca viu ou que você já usa com um ID gerado pelo StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "O portal já registrou um ID de dispositivo para este endereço MAC. Alterá-lo ou removê-lo bloqueará o acesso a esta fonte, porque o portal só aceita o ID de dispositivo que recebeu primeiro.",
"SIGNATURE_1": "Assinatura 1 (opcional)",
"SIGNATURE_2": "Assinatura 2 (opcional)",
"SIGNATURE_HINT": "64 caracteres hexadecimais - use se o provedor exigir assinaturas de verificação do dispositivo",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.",
"LOGIN_REJECTED": "O portal rejeitou o login e a senha.",
"PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.",
+ "DEVICE_CONFLICT": "O portal tem um ID de dispositivo diferente registrado para este endereço MAC.",
"PORTAL_MESSAGE": "O portal informou: {{message}}"
},
"FILTER_BY_NAME": "Filtrar por nome",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Selecione uma categoria para ver o conteúdo"
},
"STALKER_LOGIN_REQUIRED": "O portal exige login e senha. Importe o portal novamente e preencha os campos de nome de usuário e senha.",
+ "STALKER_DEVICE_CONFLICT": "O portal tem um ID de dispositivo diferente registrado para este endereço MAC. Restaure o ID de dispositivo que você usou primeiro ou peça ao seu provedor para redefinir o dispositivo deste MAC.",
"PLAYLIST_SETTINGS": "Configurações da playlist",
"HOME": "Início",
"DELETE": "Excluir"
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index 955ecd3dc..18a52d805 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Название",
"MAC_ADDRESS": "MAC-адрес",
+ "MAC_ADDRESS_HINT": "Формат 00:1A:79:XX:XX:XX. Дефисы, точки и строчные буквы исправляются автоматически.",
+ "MAC_ADDRESS_OUI_HINT": "Этот адрес находится вне диапазона 00:1A:79, зарегистрированного за Infomir. Большинство порталов принимают только этот диапазон и отклоняют остальные адреса, не объясняя причину.",
+ "MAC_ADDRESS_ERROR": "Введите MAC-адрес из 12 шестнадцатеричных символов, например 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Серийный номер (необязательно)",
"SERIAL_NUMBER_HINT": "Используйте, если уже зарегистрированы у провайдера",
"DEVICE_ID_1": "ID устройства 1 (необязательно)",
"DEVICE_ID_2": "ID устройства 2 (необязательно)",
"DEVICE_ID_HINT": "64 шестнадцатеричных символа — используйте, если провайдер требует определённый ID устройства",
+ "DERIVE_DEVICE_IDS": "Генерировать ID устройства из MAC-адреса",
+ "DERIVE_DEVICE_IDS_HINT": "Даёт то же значение, что и StbEmu (SHA-256 от MAC-адреса). Портал навсегда запоминает первый полученный ID устройства и отклоняет все последующие, поэтому включайте эту опцию только для MAC-адреса, который портал ещё ни разу не видел, или для адреса, который вы уже используете с ID, сгенерированным StbEmu.",
+ "DEVICE_ID_PINNED_WARNING": "Портал уже записал ID устройства для этого MAC-адреса. Если изменить или очистить его, доступ к этому источнику будет потерян: портал принимает только тот ID устройства, который увидел первым.",
"SIGNATURE_1": "Подпись 1 (необязательно)",
"SIGNATURE_2": "Подпись 2 (необязательно)",
"SIGNATURE_HINT": "64 шестнадцатеричных символа — используйте, если провайдер требует подписи проверки устройства",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.",
"LOGIN_REJECTED": "Портал отклонил логин и пароль.",
"PORTAL_REFUSED": "Портал отказал в доступе этому устройству.",
+ "DEVICE_CONFLICT": "Для этого MAC-адреса на портале зарегистрирован другой ID устройства.",
"PORTAL_MESSAGE": "Портал сообщил: {{message}}"
},
"FILTER_BY_NAME": "Фильтровать по имени",
@@ -964,6 +971,7 @@
"DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого"
},
"STALKER_LOGIN_REQUIRED": "Портал требует логин и пароль. Импортируйте портал заново и заполните поля имени пользователя и пароля.",
+ "STALKER_DEVICE_CONFLICT": "Для этого MAC-адреса на портале зарегистрирован другой ID устройства. Верните ID устройства, который вы использовали изначально, или попросите провайдера сбросить устройство для этого MAC-адреса.",
"PLAYLIST_SETTINGS": "Настройки плейлиста",
"HOME": "На главную",
"DELETE": "Удалить"
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index ea99b075a..2830c03ff 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "Başlık",
"MAC_ADDRESS": "MAC Adresi",
+ "MAC_ADDRESS_HINT": "Biçim: 00:1A:79:XX:XX:XX. Tireler, noktalar ve küçük harfler sizin için düzeltilir.",
+ "MAC_ADDRESS_OUI_HINT": "Bu adres, Infomir'in 00:1A:79 aralığının dışında kalıyor. Çoğu portal yalnızca bu aralığı kabul eder ve diğer adresleri nedenini belirtmeden reddeder.",
+ "MAC_ADDRESS_ERROR": "12 haneli bir MAC adresi girin, örneğin 00:1A:79:12:34:56.",
"SERIAL_NUMBER": "Seri Numarası (İsteğe Bağlı)",
"SERIAL_NUMBER_HINT": "Sağlayıcıya zaten kayıtlıysa kullanın",
"DEVICE_ID_1": "Cihaz Kimliği 1 (İsteğe Bağlı)",
"DEVICE_ID_2": "Cihaz Kimliği 2 (İsteğe Bağlı)",
"DEVICE_ID_HINT": "64 onaltılık karakter - sağlayıcı belirli bir cihaz kimliği gerektiriyorsa kullanın",
+ "DERIVE_DEVICE_IDS": "Cihaz kimliklerini MAC adresinden oluştur",
+ "DERIVE_DEVICE_IDS_HINT": "StbEmu ile aynı değeri üretir (MAC adresinin SHA-256 özeti). Portal, aldığı ilk cihaz kimliğine kalıcı olarak sabitlenir ve sonraki tüm kimlikleri reddeder; bu nedenle bunu yalnızca portalın hiç görmediği bir MAC adresi için ya da StbEmu tarafından oluşturulmuş bir kimlikle zaten kullandığınız bir MAC adresi için açın.",
+ "DEVICE_ID_PINNED_WARNING": "Portal, bu MAC adresi için bir cihaz kimliğini zaten kaydetti. Bu kimliği değiştirmek veya silmek bu kaynağa erişiminizi engeller; çünkü portal yalnızca ilk gördüğü cihaz kimliğini kabul eder.",
"SIGNATURE_1": "İmza 1 (İsteğe Bağlı)",
"SIGNATURE_2": "İmza 2 (İsteğe Bağlı)",
"SIGNATURE_HINT": "64 onaltılık karakter - sağlayıcı cihaz doğrulama imzaları gerektiriyorsa kullanın",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.",
"LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.",
"PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.",
+ "DEVICE_CONFLICT": "Portalda bu MAC adresi için farklı bir cihaz kimliği kayıtlı.",
"PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}"
},
"FILTER_BY_NAME": "İsime göre filtrele",
@@ -964,6 +971,7 @@
"DESCRIPTION": "İçeriği görmek için lütfen bir kategori seçin"
},
"STALKER_LOGIN_REQUIRED": "Portal, kullanıcı adı ve parola gerektiriyor. Portalı yeniden içe aktarın ve kullanıcı adı ile parola alanlarını doldurun.",
+ "STALKER_DEVICE_CONFLICT": "Portalda bu MAC adresi için farklı bir cihaz kimliği kayıtlı. İlk kullandığınız cihaz kimliğini geri getirin veya sağlayıcınızdan bu MAC adresi için kayıtlı cihazı sıfırlamasını isteyin.",
"PLAYLIST_SETTINGS": "Oynatma Listesi Ayarları",
"HOME": "Ana Sayfa",
"DELETE": "Sil"
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index d69b1134d..1eb615183 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "标题",
"MAC_ADDRESS": "MAC 地址",
+ "MAC_ADDRESS_HINT": "格式为 00:1A:79:XX:XX:XX。连字符、点号和小写字母会自动更正。",
+ "MAC_ADDRESS_OUI_HINT": "此地址不在 Infomir 的 00:1A:79 范围内。多数门户只接受该范围内的地址,对其他地址会直接拒绝且不作说明。",
+ "MAC_ADDRESS_ERROR": "请输入 12 位十六进制的 MAC 地址,例如 00:1A:79:12:34:56。",
"SERIAL_NUMBER": "序列号(可选)",
"SERIAL_NUMBER_HINT": "如已在提供商处注册时使用",
"DEVICE_ID_1": "设备 ID 1(可选)",
"DEVICE_ID_2": "设备 ID 2(可选)",
"DEVICE_ID_HINT": "64 位十六进制字符 — 如提供商需要特定设备 ID 时使用",
+ "DERIVE_DEVICE_IDS": "根据 MAC 地址生成设备 ID",
+ "DERIVE_DEVICE_IDS_HINT": "生成的值与 StbEmu 相同(MAC 地址的 SHA-256)。门户会锁定收到的第一个设备 ID,并拒绝之后的所有设备 ID;因此仅当该 MAC 地址从未被门户使用过,或本来就搭配 StbEmu 生成的 ID 使用时,才启用此项。",
+ "DEVICE_ID_PINNED_WARNING": "门户已为此 MAC 地址记录了设备 ID。修改或清空该 ID 会导致无法再访问此源,因为门户只接受最先收到的那个设备 ID。",
"SIGNATURE_1": "签名 1(可选)",
"SIGNATURE_2": "签名 2(可选)",
"SIGNATURE_HINT": "64 位十六进制字符 — 如提供商需要设备验证签名时使用",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。",
"LOGIN_REJECTED": "门户拒绝了该登录名和密码。",
"PORTAL_REFUSED": "门户拒绝了此设备的访问。",
+ "DEVICE_CONFLICT": "门户为此 MAC 地址注册的是另一个设备 ID。",
"PORTAL_MESSAGE": "门户返回信息:{{message}}"
},
"FILTER_BY_NAME": "按名称筛选",
@@ -964,6 +971,7 @@
"DESCRIPTION": "请选择一个分类以查看内容"
},
"STALKER_LOGIN_REQUIRED": "门户需要登录名和密码。请重新导入该门户并填写用户名和密码字段。",
+ "STALKER_DEVICE_CONFLICT": "门户为此 MAC 地址注册的是另一个设备 ID。请恢复最初使用的设备 ID,或请提供商重置此 MAC 地址对应的设备。",
"PLAYLIST_SETTINGS": "播放列表设置",
"HOME": "首页",
"DELETE": "删除"
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index ecd350563..bd2e1033d 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -184,11 +184,17 @@
"STALKER_PORTAL": {
"TITLE": "標題",
"MAC_ADDRESS": "Mac 位址",
+ "MAC_ADDRESS_HINT": "格式為 00:1A:79:XX:XX:XX。連字號、點號與小寫字母會自動為您修正。",
+ "MAC_ADDRESS_OUI_HINT": "此位址不在 Infomir 的 00:1A:79 範圍內。多數入口網站只接受該範圍的位址,並會在不說明原因的情況下拒絕其他位址。",
+ "MAC_ADDRESS_ERROR": "請輸入 12 位十六進位數字的 MAC 位址,例如 00:1A:79:12:34:56。",
"SERIAL_NUMBER": "序號(選填)",
"SERIAL_NUMBER_HINT": "若已向供應商註冊,請填寫此項",
"DEVICE_ID_1": "裝置 ID 1(選填)",
"DEVICE_ID_2": "裝置 ID 2(選填)",
"DEVICE_ID_HINT": "64 個十六進位字元——若供應商要求特定裝置 ID 時請填寫",
+ "DERIVE_DEVICE_IDS": "由 MAC 位址產生裝置 ID",
+ "DERIVE_DEVICE_IDS_HINT": "產生的值與 StbEmu 相同(MAC 的 SHA-256)。入口網站會鎖定它收到的第一組裝置 ID,並拒絕之後的所有裝置 ID;因此請只在入口網站從未見過的 MAC 位址,或您原本就搭配 StbEmu 產生的 ID 使用的 MAC 位址上啟用此選項。",
+ "DEVICE_ID_PINNED_WARNING": "入口網站已為此 MAC 位址記錄了一組裝置 ID。變更或清除該 ID 會導致您無法再存取此來源,因為入口網站只接受它最先看到的裝置 ID。",
"SIGNATURE_1": "簽章 1(選填)",
"SIGNATURE_2": "簽章 2(選填)",
"SIGNATURE_HINT": "64 個十六進位字元——若供應商要求裝置驗證簽章時請填寫",
@@ -201,6 +207,7 @@
"LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。",
"LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。",
"PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。",
+ "DEVICE_CONFLICT": "入口網站為此 MAC 位址註冊的是另一組裝置 ID。",
"PORTAL_MESSAGE": "入口網站回報:{{message}}"
},
"FILTER_BY_NAME": "依名稱篩選",
@@ -964,6 +971,7 @@
"DESCRIPTION": "請選擇一個類別以查看內容"
},
"STALKER_LOGIN_REQUIRED": "入口網站需要登入名稱與密碼。請重新匯入入口網站並填寫使用者名稱與密碼欄位。",
+ "STALKER_DEVICE_CONFLICT": "入口網站為此 MAC 位址註冊的是另一組裝置 ID。請還原您最初使用的裝置 ID,或請您的供應商重設此 MAC 位址所註冊的裝置。",
"PLAYLIST_SETTINGS": "播放清單設定",
"HOME": "首頁",
"DELETE": "刪除"
diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md
index bc9b50bd0..309d00ce1 100644
--- a/docs/architecture/stalker-portal.md
+++ b/docs/architecture/stalker-portal.md
@@ -216,8 +216,9 @@ blank fields are not generated or forwarded to `get_profile`.
values are trimmed, persisted under the canonical `stalker*` playlist fields,
and reused for initial auth, token refresh, retry auth, normal API requests,
and same-origin playback headers.
-- Empty optional identity fields remain absent. IPTVnator must not generate a
- device ID from the MAC address or duplicate `device_id2` from `device_id1`.
+- Empty optional identity fields remain absent. IPTVnator must never generate a
+ device ID behind the user's back, and must never duplicate `device_id2` from
+ `device_id1` on its own.
- The legacy default serial value `BEDACD4569BAF` is treated as absent at
runtime so older blank imports do not keep sending a synthetic serial number.
- Playback headers use the same serial normalization, so the legacy default is
@@ -225,9 +226,6 @@ blank fields are not generated or forwarded to `get_profile`.
playback requests do not synthesize `__cfduid`; when a real serial is
present, same-origin playback uses a canonical 32-character `__cfduid`
protocol cookie.
-- Generated MAG-like identity remains a future explicit setting. It must not be
- the default because strict portals can bind accounts to the first device
- fingerprint they receive.
- Stalker workspace routes must initialize `StalkerStore` from a playlist object
with an explicit `isFullStalkerPortal` mode. If the active route metadata is a
lightweight playlist record without that field, the route session must load the
@@ -235,6 +233,176 @@ blank fields are not generated or forwarded to `get_profile`.
metadata is independent from M3U playlist EPG metadata and must not depend on
M3U-specific EPG fields.
+### MAC address normalization
+
+The MAC is canonicalized to the uppercase colon form a real STB sends
+(`normalizeStalkerMacAddress` in
+`libs/shared/interfaces/src/lib/stalker-mac-address.util.ts`), accepting
+hyphens, dots, embedded whitespace or no separator at all. The same module
+exports `validateStalkerMacAddressControl`, used as a form validator by the
+import dialog and the playlist-info edit dialog; it is typed structurally
+(`{ value: unknown }`) rather than as Angular's `ValidatorFn`, because this
+library is the contract layer the Electron main process imports and must stay
+framework-free.
+
+Normalization runs **only at the input boundary** — on blur and again on
+submit, in both dialogs. The submit pass is not redundant: clicking Add or
+pressing Enter inside the field submits without the field necessarily losing
+focus, so the blur handler never runs and the raw `00-1a-79-…` would be the
+value that gets persisted and sent. Stored MAC addresses are deliberately
+never rewritten on read:
+
+- the MAC is the account key, and the bytes an already-working playlist puts
+ in its `mac` cookie are the bytes that portal accepted;
+- rewriting them at the transport would move `stalkerSessionFingerprint` for
+ every existing playlist at once, with no user action and no way to opt out.
+
+An edit therefore *does* move the fingerprint and force a re-authentication.
+That is intended: the user changed the identity, and the field shows exactly
+what will be sent.
+
+Format validity is enforced; the Infomir OUI is **advisory only**. The stock
+server's MAC filter (`enable_mac_format_validation`) is on by default and only
+accepts `00:1A:79:XX:XX:XX`, answering a bare `{status: 1}` for anything else —
+which is why `hasInfomirMacOui` drives a hint on the import field. It must stay
+a hint: reseller panels, which is what most users actually run, do not check
+the format at all, so a large share of working installations use a non-Infomir
+MAC. Refusing one would stop those users adding or editing a portal that works
+for them. The mock encodes the same split (`enforceMacFormat` is set only on
+the strict endpoint; `/portal.php` ignores it), and `AUTH_REJECTED_MAC` in
+`stalker.e2e.ts` depends on it — a non-Infomir MAC that must reach the strict
+endpoint and be refused *there*, not in the form.
+
+In the edit dialog **both** passes — blur and submit — normalize only a MAC the
+user actually changed, compared against the value the dialog was opened with
+(`isStalkerMacAddressEdited`). Renaming a playlist, or editing its EPG sources,
+must not rewrite a non-canonical MAC as a side effect: those bytes are what a
+permissive portal registered, and rewriting them would move the session
+fingerprint and re-authenticate under a spelling the portal never saw — the
+same reason a stored MAC is not rewritten on load.
+
+Guarding the submit pass alone is not enough, and this is the subtle part:
+tabbing through the dialog fires blur with no edit, and a blur that rewrites
+the control both marks the form dirty and makes the value differ from the
+stored one — which is precisely what the submit guard reads. The identity would
+then ride out on a title-only save.
+
+The edit dialog goes one step further: `createStalkerMacAddressValidator`
+grandfathers the value a playlist already stored. Before there was any
+validation the field accepted arbitrary text, and on a panel that ignores the
+MAC such a playlist works today — marking the form invalid on open would
+disable Save and strand the user's title, URL and EPG-source edits in the same
+dialog. Newly typed values are still held to the format.
+
+### Deriving device IDs from the MAC
+
+`deriveStalkerDeviceIdsFromMac` (`stalker-identity.utils.ts`) returns the pair
+StbEmu and `stalker-to-m3u` generate: uppercase hex `SHA256` of the canonical
+MAC for `device_id`, and of that MAC plus a `stalker` salt for `device_id2`.
+The import dialog offers it behind an opt-in checkbox that fills both fields.
+
+**The two values must differ.** On a real box they come from separate firmware
+calls (`gSTB.GetUID()` and `gSTB.GetUID('device_id', token)`) and are never
+equal, so an identical pair is a fingerprint no STB produces — and since the
+first non-empty value is pinned to the MAC permanently, it cannot be corrected
+afterwards. They are derived by one function returning both, so nothing can
+fill one without the other.
+
+The shape of that feature is dictated by the pinning semantics: the stock
+server binds the **first non-empty** `device_id`/`device_id2` it sees to the
+MAC permanently, refuses a different one as a device conflict, and treats a
+later empty value as a permanent lockout. Therefore:
+
+- the derived value is written into the **visible form fields** and persisted
+ as a literal string. Nothing recomputes it at request time, where a MAC edit
+ would silently re-derive it into a conflict;
+- the checkbox is offered at import only — the point where the identity is
+ being established for the first time — and is disabled when the user has
+ typed a device ID by hand;
+- while it is ticked, correcting the MAC re-derives, because nothing is pinned
+ until the import actually runs;
+- **derivation is asynchronous, and every way out of it is guarded.**
+ `applyDerivedDeviceIds` can only read the toggle before it awaits, so three
+ things protect what happens after:
+ - submitting re-runs `settleMacAddressIdentity()` and awaits it before
+ reading the form — clicking Add blurs the MAC field, so the blur's
+ `SHA256` is still in flight when the click handler runs, and a snapshot
+ taken then pairs the corrected MAC with the previous MAC's IDs;
+ - a generation stamp discards every completion but the newest, so two
+ edits in quick succession cannot leave the older pair in the fields;
+ - `invalidatePendingDerivation()` bumps that stamp whenever the user stops
+ wanting derived IDs — unticking the box, clearing the form — because an
+ outstanding digest would otherwise resolve into fields that were
+ deliberately emptied, and the import would pin IDs the user opted out
+ of.
+
+ All three are mutation-verified. Note the tests have to control when the
+ digest settles (hold it behind a gate, or delay the older invocation):
+ Node resolves digests this small in start order, so the naive versions pass
+ with the guards removed;
+- **the MAC and its device IDs travel as one value.**
+ `settleMacAddressIdentity()` reads the MAC once, before it awaits, and
+ returns it together with the IDs derived from exactly it; `addPlaylist()`
+ uses that triple rather than re-reading the form. Taking the MAC from
+ `getRawValue()` after the digest would ship a newly typed address paired
+ with the previous one's IDs, which the portal pins as a permanent device
+ conflict. The whole form is also frozen (`form.disable()`) for the duration
+ of an import and restored in `finally`, since an edit made then can neither
+ reach the portal nor be undone on it;
+- **the snapshot `addPlaylist()` takes is authoritative for the whole import,
+ by design.** Discovery authenticates with exactly those values, and
+ `get_profile` is what pins them to the MAC — so by the time a slow discovery
+ answers, the portal has already committed. Re-reading the form afterwards to
+ pick up a mid-flight edit would persist device IDs that differ from the
+ pinned ones, or none at all, and sending nothing after a value was pinned is
+ the permanent lockout. The identity toggle is therefore locked while
+ `isLoading()` rather than the submission being re-snapshotted: the UI must
+ not imply an opt-out that cannot exist;
+- an unusable MAC (or a runtime without WebCrypto) derives nothing rather than
+ hashing a typo into a permanent binding;
+- the playlist-info edit dialog offers no derivation. It shows
+ `DEVICE_ID_PINNED_WARNING` instead once a device ID has actually reached the
+ portal. **Storage is not transmission**: `device_id` travels only on
+ `get_profile`/`do_auth`, which a simple panel-style portal never runs, and
+ the import's offline fallback persists whatever was typed while recording
+ the playlist as simple. `hasStoredStalkerDeviceIds` therefore gates on
+ `isFullStalkerPortal` — telling those users a change "will lock this source
+ out" would be false and would discourage them from fixing an ID that was
+ never pinned.
+
+ **Known imprecision, deliberately not closed here.** The gate proves that
+ *some* device ID reached the portal, not that the currently stored one did:
+ a user who edits the ID after import keeps `isFullStalkerPortal` true while
+ the new value has never seen `get_profile`. The copy is hedged for exactly
+ that ("a device ID", not "this one") and the fields stay editable, so the
+ remedy — restoring the ID that was pinned — is never blocked. Making it
+ exact needs a per-identity confirmation signal;
+ `Playlist.stalkerSessionIdentity` already carries a session fingerprint that
+ would serve, but reading it here would make a `type:ui` playlist library
+ depend on the Stalker data-access lib, which the Nx boundaries forbid. Worth
+ revisiting behind a shared contract, not worth a boundary exception.
+
+The trade-off the option exists for is interoperability, not obfuscation: a
+user who reaches the same account from StbEmu already has this exact value
+pinned, and IPTVnator has to send it or be refused.
+
+### Reported device profile
+
+`get_profile` carries a fixed MAG250 description from
+`STALKER_STB_PROFILE_PARAMS`
+(`libs/shared/interfaces/src/lib/stalker-stb-profile.const.ts`): `ver`,
+`stb_type` (`MAG250`, previously sent as an empty string), `hw_version`,
+`image_version`, `client_type`, plus the `num_banks`/`video_out`/`hd` the
+request already carried. The stock middleware stores these for the admin panel
+and only an operator's optional `access_filter.php` inspects them, so they are
+free to send — but they must describe the same box as `metrics.model` and the
+`STALKER_MAG_USER_AGENT` header, or the profile reads as a forgery.
+
+They are constants, identical for every playlist, and deliberately excluded
+from `stalkerIdentityFingerprint` / `stalkerSessionFingerprint`: including them
+would invalidate every persisted session for no gain, since the portal binds
+nothing to them.
+
## Session Authentication Lifecycle
Full portals authenticate through `StalkerSessionService`
@@ -306,9 +474,11 @@ every start.
- full profile / `status: 0` — OK; `watchdog_timeout` and `timeslot` are read
for the watchdog cadence.
-- `status: 1` — blocked (device conflict, malformed MAC, disabled account).
+- `status: 1` — refused (device conflict, malformed MAC, disabled account).
`msg`/`block_msg` carry the portal's own explanation; they are
- markup-stripped, combined, and thrown as `StalkerPortalError('blocked')`.
+ markup-stripped, combined, and thrown as `StalkerPortalError`. The kind is
+ `device-conflict` when `isStalkerDeviceConflictMessage` matches the combined
+ text, otherwise `blocked` — see "Device conflicts" below.
- `status: 2` — login/password required. The client runs `do_auth`
(`login`, `password`, plus `device_id`/`device_id2` when configured) and
retries `get_profile` with `auth_second_step=1`. Only that retry claims the
@@ -357,6 +527,26 @@ request that already succeeded. `do_auth` itself answers a bare `{js: false}`,
so a rejection there keeps the profile's text — the one that asked for the
login.
+### Device conflicts
+
+A device conflict is the one refusal with a concrete remedy, and the one where
+relaying the portal verbatim actively misleads: the stock server answers
+`{status: 1, msg: "device conflict — device_id mismatch", block_msg: "Your STB
+is damaged…"}`, and hardware failure is not what happened. It therefore gets
+its own `StalkerPortalErrorKind` and its own headline — the import dialog maps
+it to `HOME.STALKER_PORTAL.DEVICE_CONFLICT`, and the workspace context panel is
+the single place that overrides its otherwise kind-agnostic "portal text wins"
+rule: `PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT` leads, the portal's own
+sentence follows.
+
+`isStalkerDeviceConflictMessage` (`stalker-portal-error.ts`) matches a small
+phrase set against `msg`/`block_msg`. Two boundaries are deliberate: it reads a
+STRUCTURED field the middleware wrote, so a phrase set is safe here in a way it
+would not be against a raw HTML body (the asymmetry documented for
+`isStalkerAuthFailureBody`); and it stays narrow around the binding itself,
+because "device limit reached" or "no device selected" are different refusals
+and offering "restore your first device ID" for them is a dead end.
+
### Abandoning an authentication
`authenticate()` takes an optional `AbortSignal` and checks it before every
@@ -1216,6 +1406,21 @@ branching and the cross-surface series contract live in:
- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts`
- `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts`
+Identity hardening (MAC normalization, derived device IDs, the reported device
+profile and device-conflict classification) is covered by:
+
+- `libs/shared/interfaces/src/lib/stalker-mac-address.util.spec.ts`
+- `libs/shared/interfaces/src/lib/stalker-identity.utils.spec.ts`
+- `libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts`
+- `libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts`
+- `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts`
+- `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts`
+- `apps/web-e2e/src/stalker.e2e.ts` — "explains a device conflict instead of
+ relaying 'STB is damaged'", which pins a device ID through the proxy and then
+ imports with a different one. Its negative assertion (the generic "refused
+ access" headline must be absent) is what makes it fail if the classification
+ is removed; verified by mutation.
+
Covered scenarios include:
- Embedded `series[]` opens series view state
diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-import-identity.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-import-identity.ts
new file mode 100644
index 000000000..6581e9951
--- /dev/null
+++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-import-identity.ts
@@ -0,0 +1,51 @@
+import type { StalkerPortalErrorKind } from '@iptvnator/portal/stalker/data-access';
+import type { StalkerPortalIdentity } from '@iptvnator/shared/interfaces';
+
+/** The `stalker*` playlist columns the import form writes. */
+export interface StalkerPlaylistIdentityFields {
+ stalkerSerialNumber?: string;
+ stalkerDeviceId1?: string;
+ stalkerDeviceId2?: string;
+ stalkerSignature1?: string;
+ stalkerSignature2?: string;
+}
+
+/**
+ * Maps the form's identity object onto the playlist's `stalker*` columns,
+ * omitting every empty field. Absence is meaningful: an identity value the
+ * portal has already pinned must keep being sent, and one it has not seen must
+ * keep being absent — writing `''` instead of omitting would send an empty
+ * `device_id`, which is how an account gets locked out permanently.
+ */
+export function toStalkerPlaylistIdentityFields(
+ identity: StalkerPortalIdentity
+): StalkerPlaylistIdentityFields {
+ return {
+ ...(identity.serialNumber
+ ? { stalkerSerialNumber: identity.serialNumber }
+ : {}),
+ ...(identity.deviceId1
+ ? { stalkerDeviceId1: identity.deviceId1 }
+ : {}),
+ ...(identity.deviceId2
+ ? { stalkerDeviceId2: identity.deviceId2 }
+ : {}),
+ ...(identity.signature1
+ ? { stalkerSignature1: identity.signature1 }
+ : {}),
+ ...(identity.signature2
+ ? { stalkerSignature2: identity.signature2 }
+ : {}),
+ };
+}
+
+/** Headline shown for each way a portal can refuse the import. */
+export const STALKER_IMPORT_ERROR_KEY_BY_KIND: Readonly<
+ Record
+> = {
+ 'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
+ 'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
+ 'device-conflict': 'HOME.STALKER_PORTAL.DEVICE_CONFLICT',
+ blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
+ 'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
+};
diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html
index 57beb00fe..c2491114f 100644
--- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html
+++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html
@@ -20,7 +20,7 @@
}}
{{ 'SETTINGS.EPG_URL_ERROR' | translate }}
-
+ {{
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
}}
@@ -29,7 +29,20 @@
type="text"
id="macAddress"
formControlName="macAddress"
+ (blur)="onMacAddressBlur()"
/>
+ @if (showsForeignOuiHint) {
+ {{
+ 'HOME.STALKER_PORTAL.MAC_ADDRESS_OUI_HINT' | translate
+ }}
+ } @else {
+ {{
+ 'HOME.STALKER_PORTAL.MAC_ADDRESS_HINT' | translate
+ }}
+ }
+ {{
+ 'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
+ }}{{
@@ -45,6 +58,19 @@
'HOME.STALKER_PORTAL.SERIAL_NUMBER_HINT' | translate
}}
+
{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts
index 906afa722..899c7e992 100644
--- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts
+++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts
@@ -1,3 +1,4 @@
+import { webcrypto } from 'node:crypto';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
@@ -16,6 +17,23 @@ describe('StalkerPortalImportComponent identity handling', () => {
let store: { dispatch: jest.Mock };
let snackBar: { open: jest.Mock };
+ // jsdom ships no WebCrypto. Install Node's real implementation rather
+ // than a stub, so the derived device IDs asserted below are the values a
+ // portal would actually be handed.
+ const originalCrypto = globalThis.crypto;
+ beforeAll(() => {
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: webcrypto,
+ });
+ });
+ afterAll(() => {
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: originalCrypto,
+ });
+ });
+
beforeEach(() => {
portalDiscovery = {
discover: jest.fn().mockResolvedValue({
@@ -329,6 +347,542 @@ describe('StalkerPortalImportComponent identity handling', () => {
);
});
+ describe('MAC address handling', () => {
+ it('canonicalizes the typed MAC on blur', async () => {
+ component.form.patchValue({ macAddress: '00-1a-79-ab-cd-ef' });
+
+ await component.onMacAddressBlur();
+
+ expect(component.form.controls.macAddress.value).toBe(
+ '00:1A:79:AB:CD:EF'
+ );
+ });
+
+ it('rejects a malformed MAC before anything reaches the portal', async () => {
+ component.form.patchValue({
+ _id: 'playlist-bad-mac',
+ title: 'Typo Portal',
+ macAddress: '00:1A:79:AA:BB',
+ portalUrl: 'https://portal.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+
+ expect(component.form.controls.macAddress.valid).toBe(false);
+
+ await component.addPlaylist();
+
+ expect(portalDiscovery.discover).not.toHaveBeenCalled();
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('canonicalizes a submitted MAC that was never blurred', async () => {
+ // Keyboard users can reach Add without the field losing focus.
+ component.form.patchValue({
+ _id: 'playlist-unblurred',
+ title: 'Panel',
+ macAddress: '001a79aabbcc',
+ portalUrl: 'https://portal.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+
+ await component.addPlaylist();
+
+ expect(portalDiscovery.discover).toHaveBeenCalledWith(
+ 'https://portal.example.com/c',
+ '00:1A:79:AA:BB:CC',
+ expect.any(Object),
+ expect.any(Object)
+ );
+ expect(store.dispatch.mock.calls[0][0].playlist.macAddress).toBe(
+ '00:1A:79:AA:BB:CC'
+ );
+ });
+
+ it('imports a MAC outside the Infomir range', async () => {
+ // The stock portal's OUI filter is off on most reseller panels, so
+ // a non-Infomir MAC is a working setup for a lot of users. The
+ // hint explains what stock Ministra will do; it must not block.
+ component.form.patchValue({
+ _id: 'playlist-foreign-oui',
+ title: 'Reseller Panel',
+ macAddress: 'AA:BB:CC:DD:EE:01',
+ portalUrl: 'https://panel.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+
+ expect(component.form.controls.macAddress.valid).toBe(true);
+ expect(component.showsForeignOuiHint).toBe(true);
+
+ await component.addPlaylist();
+
+ expect(portalDiscovery.discover).toHaveBeenCalledWith(
+ 'https://panel.example.com/c',
+ 'AA:BB:CC:DD:EE:01',
+ expect.any(Object),
+ expect.any(Object)
+ );
+ expect(store.dispatch.mock.calls[0][0].playlist.macAddress).toBe(
+ 'AA:BB:CC:DD:EE:01'
+ );
+ });
+
+ it('hints only when the MAC is valid but outside the Infomir range', () => {
+ expect(component.showsForeignOuiHint).toBe(false);
+
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ expect(component.showsForeignOuiHint).toBe(false);
+
+ component.form.patchValue({ macAddress: '00:1B:79:AA:BB:CC' });
+ expect(component.showsForeignOuiHint).toBe(true);
+
+ // A half-typed address is an error, not an OUI warning.
+ component.form.patchValue({ macAddress: '00:1B' });
+ expect(component.showsForeignOuiHint).toBe(false);
+ });
+ });
+
+ describe('device ID derivation', () => {
+ // Uppercase hex SHA-256 of the canonical MAC, and of that MAC plus
+ // the `stalker` salt — the values StbEmu and stalker-to-m3u pin
+ // server-side. Asserted literally: matching those clients byte for
+ // byte is the entire point of the option.
+ const DERIVED_FOR_AABBCC = {
+ deviceId1:
+ '21DA59C248805FDF0F36FA2C4CA4569E10D1F80268D8104C7AF8BB776D657ED8',
+ deviceId2:
+ 'C6BA0906206A93A6CC4B6C2E94AC92EBC1A217784B692979DB373FABE0B3D2F5',
+ };
+
+ it('fills both device IDs with the StbEmu-compatible pair', async () => {
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+
+ await component.toggleDeriveDeviceIds(true);
+
+ expect(component.form.controls.deviceId1.value).toBe(
+ DERIVED_FOR_AABBCC.deviceId1
+ );
+ // A real box reports the two from separate firmware calls and they
+ // are never equal; the portal pins them permanently, so an
+ // identical pair could not be corrected later.
+ expect(component.form.controls.deviceId2.value).toBe(
+ DERIVED_FOR_AABBCC.deviceId2
+ );
+ expect(component.form.controls.deviceId2.value).not.toBe(
+ component.form.controls.deviceId1.value
+ );
+ // Derived values are shown, not hidden state — but they are not
+ // hand-editable while derivation owns them.
+ expect(component.form.controls.deviceId1.disabled).toBe(true);
+ expect(component.form.controls.deviceId2.disabled).toBe(true);
+ });
+
+ it('persists the derived IDs as literal values', async () => {
+ component.form.patchValue({
+ _id: 'playlist-derived',
+ title: 'Derived Portal',
+ macAddress: '00:1A:79:AA:BB:CC',
+ portalUrl: 'https://portal.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+ await component.toggleDeriveDeviceIds(true);
+
+ await component.addPlaylist();
+
+ // Disabled controls still have to reach the playlist, and they
+ // have to arrive as strings — nothing may recompute them later,
+ // when a MAC edit would turn them into a device conflict.
+ const playlist = store.dispatch.mock.calls[0][0].playlist;
+ expect(playlist.stalkerDeviceId1).toBe(
+ DERIVED_FOR_AABBCC.deviceId1
+ );
+ expect(playlist.stalkerDeviceId2).toBe(
+ DERIVED_FOR_AABBCC.deviceId2
+ );
+ });
+
+ it('follows a corrected MAC while the box is still ticked', async () => {
+ // Nothing is pinned until the import actually runs, so fixing a
+ // typo has to fix the ID it would bind the account to.
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ await component.toggleDeriveDeviceIds(true);
+
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
+ await component.onMacAddressBlur();
+
+ expect(component.form.controls.deviceId1.value).toBe(
+ 'A1474C4E43345F99C018F151C2D401A0231CFADC310E2514944641590F9C4504'
+ );
+ expect(component.form.controls.deviceId2.value).toBe(
+ 'EF401CECA8498585809B8D0FC20640A51148B72343D39DBC0A442AD26ED7A8DD'
+ );
+ });
+
+ it('does not submit a MAC paired with the previous MAC\'s IDs', async () => {
+ // Clicking Add blurs the MAC field, so the blur's SHA-256 is
+ // still in flight when the click handler runs. Snapshotting the
+ // form there pairs the corrected MAC with the old MAC's device
+ // IDs — and the portal pins that pairing permanently.
+ component.form.patchValue({
+ _id: 'playlist-race',
+ title: 'Race Portal',
+ macAddress: '00:1A:79:AA:BB:CC',
+ portalUrl: 'https://portal.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+ await component.toggleDeriveDeviceIds(true);
+
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
+ const blur = component.onMacAddressBlur();
+ await component.addPlaylist();
+ await blur;
+
+ const playlist = store.dispatch.mock.calls[0][0].playlist;
+ expect(playlist.macAddress).toBe('00:1A:79:AA:BB:CD');
+ expect(playlist.stalkerDeviceId1).toBe(
+ 'A1474C4E43345F99C018F151C2D401A0231CFADC310E2514944641590F9C4504'
+ );
+ expect(playlist.stalkerDeviceId2).toBe(
+ 'EF401CECA8498585809B8D0FC20640A51148B72343D39DBC0A442AD26ED7A8DD'
+ );
+ });
+
+ it('discards a derivation the next MAC edit superseded', async () => {
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ await component.toggleDeriveDeviceIds(true);
+
+ // Two digests end up in flight at once, and nothing guarantees
+ // they settle in the order they started. Node resolves them in
+ // order for inputs this small, which would let this test pass
+ // with no guard at all — so the older pair is explicitly held
+ // back until the newer one has landed.
+ const realDigest = webcrypto.subtle.digest.bind(webcrypto.subtle);
+ let call = 0;
+ const digest = jest
+ .spyOn(globalThis.crypto.subtle, 'digest')
+ .mockImplementation((async (
+ algorithm: AlgorithmIdentifier,
+ data: BufferSource
+ ) => {
+ // One derivation is two digests, so the first invocation
+ // is calls 0 and 1.
+ const delayed = call++ < 2;
+ const result = await realDigest(algorithm, data);
+ if (delayed) {
+ await new Promise((resolve) =>
+ setTimeout(resolve, 20)
+ );
+ }
+ return result;
+ }) as typeof globalThis.crypto.subtle.digest);
+
+ try {
+ const superseded = component.onMacAddressBlur();
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
+ const latest = component.onMacAddressBlur();
+ await Promise.all([latest, superseded]);
+ } finally {
+ digest.mockRestore();
+ }
+
+ expect(component.form.controls.deviceId1.value).toBe(
+ 'A1474C4E43345F99C018F151C2D401A0231CFADC310E2514944641590F9C4504'
+ );
+ expect(component.form.controls.deviceId2.value).toBe(
+ 'EF401CECA8498585809B8D0FC20640A51148B72343D39DBC0A442AD26ED7A8DD'
+ );
+ });
+
+ /**
+ * Holds every digest until `release()` is called, so a toggle can be
+ * observed landing WHILE one is in flight. Without this the digest
+ * settles first and the assertions below hold either way.
+ */
+ function holdDigests(): {
+ release: () => void;
+ restore: () => void;
+ } {
+ const realDigest = webcrypto.subtle.digest.bind(webcrypto.subtle);
+ let release = (): void => undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const spy = jest
+ .spyOn(globalThis.crypto.subtle, 'digest')
+ .mockImplementation((async (
+ algorithm: AlgorithmIdentifier,
+ data: BufferSource
+ ) => {
+ await gate;
+ return realDigest(algorithm, data);
+ }) as typeof globalThis.crypto.subtle.digest);
+
+ return { release, restore: () => spy.mockRestore() };
+ }
+
+ it('does not repopulate the fields when the box is unticked mid-digest', async () => {
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ const { release, restore } = holdDigests();
+
+ try {
+ const pending = component.toggleDeriveDeviceIds(true);
+ await component.toggleDeriveDeviceIds(false);
+ release();
+ await pending;
+ } finally {
+ restore();
+ }
+
+ // The user opted out; a digest that was already running must not
+ // put IDs back that the portal would then pin permanently.
+ expect(component.derivesDeviceIds()).toBe(false);
+ expect(component.form.controls.deviceId1.value).toBe('');
+ expect(component.form.controls.deviceId2.value).toBe('');
+ });
+
+ it('does not repopulate the fields when the form is cleared mid-digest', async () => {
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ const { release, restore } = holdDigests();
+
+ try {
+ const pending = component.toggleDeriveDeviceIds(true);
+ component.clearForm();
+ release();
+ await pending;
+ } finally {
+ restore();
+ }
+
+ expect(component.form.controls.deviceId1.value).toBe('');
+ expect(component.form.controls.deviceId2.value).toBe('');
+ });
+
+ it('leaves the fields alone once the box is unticked', async () => {
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ await component.toggleDeriveDeviceIds(true);
+ await component.toggleDeriveDeviceIds(false);
+
+ expect(component.form.controls.deviceId1.value).toBe('');
+ expect(component.form.controls.deviceId1.enabled).toBe(true);
+
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
+ await component.onMacAddressBlur();
+
+ expect(component.form.controls.deviceId1.value).toBe('');
+ });
+
+ it('keeps the identity it authenticated with when unticked mid-import', async () => {
+ // Discovery has already sent these IDs to the portal by the time a
+ // slow answer comes back, so the portal has pinned them. Persisting
+ // what the user unticked to instead — nothing — is the permanent
+ // lockout, so the snapshot has to win. The toggle is locked while
+ // the import runs precisely so the UI cannot imply otherwise.
+ component.form.patchValue({
+ _id: 'playlist-slow-discovery',
+ title: 'Slow Portal',
+ macAddress: '00:1A:79:AA:BB:CC',
+ portalUrl: 'https://portal.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+ await component.toggleDeriveDeviceIds(true);
+
+ let finishDiscovery = (): void => undefined;
+ portalDiscovery.discover.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishDiscovery = () =>
+ resolve({
+ status: 'resolved',
+ portalUrl: 'https://portal.example.com/c',
+ isFullStalkerPortal: false,
+ });
+ })
+ );
+
+ const importing = component.addPlaylist();
+ // Submitting settles the derivation first, so several microtasks
+ // pass before discovery is reached; poll rather than guess.
+ for (
+ let i = 0;
+ i < 100 && portalDiscovery.discover.mock.calls.length === 0;
+ i += 1
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ }
+
+ expect(portalDiscovery.discover).toHaveBeenCalledTimes(1);
+ expect(component.isLoading()).toBe(true);
+ expect(component.hasManualDeviceIds).toBe(true);
+
+ // Even if the toggle were reachable, the import must not change.
+ await component.toggleDeriveDeviceIds(false);
+ finishDiscovery();
+ await importing;
+
+ const playlist = store.dispatch.mock.calls[0][0].playlist;
+ expect(playlist.stalkerDeviceId1).toBe(
+ DERIVED_FOR_AABBCC.deviceId1
+ );
+ expect(playlist.stalkerDeviceId2).toBe(
+ DERIVED_FOR_AABBCC.deviceId2
+ );
+ });
+
+ it('never pairs one MAC with another MAC\'s device IDs', async () => {
+ // The submit-time digest is asynchronous, so a MAC edit can land
+ // while it runs. Reading the MAC from the form afterwards would
+ // ship the new address with the old address's IDs — a mismatch
+ // the portal pins permanently as a device conflict.
+ component.form.patchValue({
+ _id: 'playlist-desync',
+ title: 'Desync Portal',
+ macAddress: '00:1A:79:AA:BB:CC',
+ portalUrl: 'https://portal.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+ await component.toggleDeriveDeviceIds(true);
+
+ const { release, restore } = holdDigests();
+ try {
+ const importing = component.addPlaylist();
+ await Promise.resolve();
+ // Simulates the field changing mid-digest.
+ component.form.controls.macAddress.setValue(
+ '00:1A:79:AA:BB:CD'
+ );
+ release();
+ await importing;
+ } finally {
+ restore();
+ }
+
+ const playlist = store.dispatch.mock.calls[0][0].playlist;
+ expect(playlist.macAddress).toBe('00:1A:79:AA:BB:CC');
+ expect(playlist.stalkerDeviceId1).toBe(
+ DERIVED_FOR_AABBCC.deviceId1
+ );
+ expect(playlist.stalkerDeviceId2).toBe(
+ DERIVED_FOR_AABBCC.deviceId2
+ );
+ expect(portalDiscovery.discover).toHaveBeenCalledWith(
+ expect.any(String),
+ '00:1A:79:AA:BB:CC',
+ expect.objectContaining({
+ deviceId1: DERIVED_FOR_AABBCC.deviceId1,
+ }),
+ expect.any(Object)
+ );
+ });
+
+ it('freezes the identity fields while the import runs', async () => {
+ component.form.patchValue({
+ _id: 'playlist-frozen',
+ title: 'Frozen Portal',
+ macAddress: '00:1A:79:AA:BB:CC',
+ portalUrl: 'https://portal.example.com/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+
+ await component.toggleDeriveDeviceIds(true);
+
+ let finishDiscovery = (): void => undefined;
+ portalDiscovery.discover.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishDiscovery = () =>
+ resolve({
+ status: 'unreachable',
+ });
+ })
+ );
+
+ const importing = component.addPlaylist();
+ for (
+ let i = 0;
+ i < 100 && portalDiscovery.discover.mock.calls.length === 0;
+ i += 1
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ }
+
+ expect(component.form.controls.macAddress.disabled).toBe(true);
+ expect(component.form.controls.title.disabled).toBe(true);
+
+ finishDiscovery();
+ await importing;
+
+ // Restored afterwards, with derivation keeping its own lock.
+ expect(component.form.controls.macAddress.enabled).toBe(true);
+ expect(component.form.controls.deviceId1.disabled).toBe(true);
+ });
+
+ it('refuses to overwrite a hand-entered device ID', () => {
+ expect(component.hasManualDeviceIds).toBe(false);
+
+ component.form.patchValue({ deviceId1: 'PROVIDER-SUPPLIED' });
+
+ expect(component.hasManualDeviceIds).toBe(true);
+ });
+
+ it('derives nothing while the MAC is unusable', async () => {
+ component.form.patchValue({ macAddress: 'not-a-mac' });
+
+ await component.toggleDeriveDeviceIds(true);
+
+ // Hashing a typo would pin the account to it permanently.
+ expect(component.form.controls.deviceId1.value).toBe('');
+ expect(component.form.controls.deviceId2.value).toBe('');
+ });
+
+ it('clears derivation state on form reset', async () => {
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ await component.toggleDeriveDeviceIds(true);
+
+ component.clearForm();
+
+ expect(component.derivesDeviceIds()).toBe(false);
+ expect(component.form.controls.deviceId1.enabled).toBe(true);
+ expect(component.form.controls.deviceId1.value).toBe('');
+ });
+
+ it('is deterministic across input formatting', async () => {
+ component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
+ await component.toggleDeriveDeviceIds(true);
+ const canonical = component.form.controls.deviceId1.value;
+
+ component.form.patchValue({ macAddress: '00-1a-79-aa-bb-cc' });
+ await component.onMacAddressBlur();
+
+ expect(canonical).toBe(DERIVED_FOR_AABBCC.deviceId1);
+ expect(component.form.controls.deviceId1.value).toBe(canonical);
+ });
+ });
+
+ it('gives a device conflict its own headline', async () => {
+ portalDiscovery.discover.mockResolvedValue({
+ status: 'auth-rejected',
+ portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
+ error: new StalkerPortalError(
+ 'device-conflict',
+ 'device conflict - device_id mismatch'
+ ),
+ });
+ component.form.patchValue({
+ _id: 'playlist-conflict',
+ title: 'Conflicting Portal',
+ macAddress: '00:1A:79:AA:BB:CC',
+ portalUrl: 'https://portal.example.com/stalker_portal/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+
+ await component.addPlaylist();
+
+ expect(snackBar.open).toHaveBeenCalledWith(
+ 'HOME.STALKER_PORTAL.DEVICE_CONFLICT HOME.STALKER_PORTAL.PORTAL_MESSAGE',
+ undefined,
+ expect.any(Object)
+ );
+ });
+
it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => {
// Offline panel: discovery finds nothing, the legacy guess imports
// anyway — but the suffix rewrite must run on the PATH, or
diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts
index 86888fb92..f3634a28c 100644
--- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts
+++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts
@@ -6,6 +6,7 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms';
+import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -18,20 +19,39 @@ import {
normalizeStalkerPortalInputUrl,
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
StalkerPortalDiscoveryService,
- StalkerPortalIdentity,
normalizeStalkerPortalIdentity,
stalkerSessionFingerprint,
- type StalkerPortalErrorKind,
} from '@iptvnator/portal/stalker/data-access';
import {
createRandomId,
+ deriveStalkerDeviceIdsFromMac,
+ hasInfomirMacOui,
isFullStalkerPortalUrl,
+ normalizeStalkerMacAddress,
Playlist,
+ type StalkerDerivedDeviceIds,
+ validateStalkerMacAddressControl,
} from '@iptvnator/shared/interfaces';
+import {
+ STALKER_IMPORT_ERROR_KEY_BY_KIND,
+ toStalkerPlaylistIdentityFields,
+} from './stalker-import-identity';
+
+/**
+ * A MAC and the device IDs that belong to exactly it. Kept together because
+ * the portal binds the pair permanently on first use — a MAC carrying another
+ * address's IDs is a device conflict nobody can undo.
+ */
+interface StalkerSettledIdentity {
+ macAddress: string;
+ deviceId1: string;
+ deviceId2: string;
+}
@Component({
imports: [
FormsModule,
+ MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
@@ -56,6 +76,17 @@ import {
align-items: center;
gap: 8px;
}
+
+ .derive-device-ids {
+ margin: 4px 0 12px;
+ }
+
+ .derive-device-ids__note {
+ margin: 4px 0 0;
+ color: var(--mat-sys-on-surface-variant);
+ font-size: 12px;
+ line-height: 1.45;
+ }
`,
],
})
@@ -66,7 +97,10 @@ export class StalkerPortalImportComponent {
readonly form = new FormGroup({
_id: new FormControl(createRandomId()),
title: new FormControl('', [Validators.required]),
- macAddress: new FormControl('', [Validators.required]),
+ macAddress: new FormControl('', [
+ Validators.required,
+ validateStalkerMacAddressControl,
+ ]),
serialNumber: new FormControl(''),
deviceId1: new FormControl(''),
deviceId2: new FormControl(''),
@@ -89,7 +123,184 @@ export class StalkerPortalImportComponent {
readonly isLoading = signal(false);
+ /** Whether the device IDs are being generated from the MAC. */
+ readonly derivesDeviceIds = signal(false);
+
+ /** Stamps each derivation so a late one cannot overwrite a newer one. */
+ private deriveGeneration = 0;
+
+ /**
+ * A MAC outside Infomir's range is imported anyway — plenty of resellers
+ * disable the check — but the stock portal answers a bare `{status: 1}`
+ * for it, so the hint has to say where that dead end comes from.
+ */
+ get showsForeignOuiHint(): boolean {
+ const value = this.form.controls.macAddress.value;
+ return Boolean(normalizeStalkerMacAddress(value)) &&
+ !hasInfomirMacOui(value);
+ }
+
+ /**
+ * The toggle is unavailable while an import is running, and while
+ * derivation would overwrite a device ID the user entered by hand.
+ *
+ * Locking it during the import is not cosmetic. `addPlaylist()` snapshots
+ * the identity and then hands it to portal discovery, which authenticates
+ * with it — so by the time a slow discovery returns, the portal has
+ * already pinned those IDs to the MAC. The snapshot is therefore the only
+ * correct thing to persist, and unticking mid-flight cannot change that.
+ * Leaving the box live would show the fields emptying and imply the
+ * opposite.
+ */
+ get hasManualDeviceIds(): boolean {
+ if (this.isLoading()) {
+ return true;
+ }
+
+ return (
+ !this.derivesDeviceIds() &&
+ Boolean(
+ this.form.controls.deviceId1.value ||
+ this.form.controls.deviceId2.value
+ )
+ );
+ }
+
+ /**
+ * Rewrites what the user typed into the canonical `00:1A:79:…` form on
+ * blur. Only input is normalized: the field shows the exact bytes that
+ * will go into the `mac` cookie, so the change is visible and editable
+ * rather than something the transport does silently later.
+ */
+ async onMacAddressBlur(): Promise {
+ await this.settleMacAddressIdentity();
+ }
+
+ /**
+ * Brings the MAC field and the derived device IDs into agreement, and
+ * resolves only once they are.
+ *
+ * Both the blur handler and the submit path go through here. Submitting
+ * has to re-run it rather than trust the blur: clicking Add moves focus
+ * out of the field, so the blur's `SHA256` is still in flight when Angular
+ * invokes the click handler — and a form read at that moment pairs the
+ * corrected MAC with the PREVIOUS MAC's device IDs (or with empty ones).
+ * The portal pins whatever pair it first receives to that MAC
+ * permanently, so there is no recovering from it afterwards. Re-running
+ * also covers the case where no blur fired at all.
+ */
+ private async settleMacAddressIdentity(): Promise {
+ const control = this.form.controls.macAddress;
+ const normalized = normalizeStalkerMacAddress(control.value);
+
+ if (normalized && normalized !== control.value) {
+ control.setValue(normalized);
+ }
+
+ // Read ONCE, before the await. Everything returned below describes
+ // this MAC, so a field edit landing while the digest runs cannot
+ // produce a snapshot whose device IDs belong to a different address —
+ // the pairing the portal would then pin permanently.
+ const macAddress = normalized ?? '';
+
+ // Re-derive while the box is ticked: nothing is pinned until the
+ // import actually runs, so correcting a typo must correct the ID it
+ // would otherwise bind the account to forever.
+ const derived = await this.applyDerivedDeviceIds(macAddress);
+
+ return {
+ macAddress,
+ deviceId1:
+ derived?.deviceId1 ?? this.form.controls.deviceId1.value ?? '',
+ deviceId2:
+ derived?.deviceId2 ?? this.form.controls.deviceId2.value ?? '',
+ };
+ }
+
+ /**
+ * Fills both device ID fields with the StbEmu / stalker-to-m3u pair
+ * (`SHA256(MAC)` and `SHA256(MAC + "stalker")`, which a real box never
+ * reports as equal) — or empties them again.
+ *
+ * The derived values are written into the visible fields and persisted as
+ * literal strings, never recomputed at request time. `device_id` is pinned
+ * to the MAC by the portal on first use: a value that silently followed a
+ * later MAC edit would be refused as a device conflict, and one that
+ * silently disappeared would lock the account out for good.
+ */
+ async toggleDeriveDeviceIds(enabled: boolean): Promise {
+ this.derivesDeviceIds.set(enabled);
+ const { deviceId1, deviceId2 } = this.form.controls;
+
+ if (!enabled) {
+ // Turning it off has to invalidate work already in flight, or the
+ // digest started a moment ago lands afterwards and writes the IDs
+ // straight back into the fields the user just opted out of —
+ // which then reach the portal and get pinned permanently.
+ this.invalidatePendingDerivation();
+ deviceId1.enable();
+ deviceId2.enable();
+ this.form.patchValue({ deviceId1: '', deviceId2: '' });
+ return;
+ }
+
+ deviceId1.disable();
+ deviceId2.disable();
+ // Through the same single-read path as blur and submit, so the MAC
+ // the IDs are derived from is never read twice.
+ await this.settleMacAddressIdentity();
+ }
+
+ /**
+ * Makes every derivation currently in flight a no-op.
+ *
+ * `applyDerivedDeviceIds` can only check the toggle before it awaits, so
+ * anything that stops the user from wanting derived IDs — unticking the
+ * box, clearing the form — has to invalidate the outstanding digest here
+ * as well. Otherwise it resolves into fields that were deliberately
+ * emptied, and the portal pins whatever the import then sends.
+ */
+ private invalidatePendingDerivation(): void {
+ this.deriveGeneration += 1;
+ }
+
+ /**
+ * Writes the derived pair into the form and returns it, or `null` when
+ * derivation is off or the result was superseded before it landed.
+ */
+ private async applyDerivedDeviceIds(
+ macAddress: string
+ ): Promise {
+ if (!this.derivesDeviceIds()) {
+ return null;
+ }
+
+ // Two edits in quick succession leave two digests in flight, and
+ // nothing guarantees they resolve in the order they were started. The
+ // generation stamp discards every completion but the newest, so the
+ // fields can never end up holding an older MAC's IDs.
+ const generation = ++this.deriveGeneration;
+ const derived = await deriveStalkerDeviceIdsFromMac(macAddress);
+
+ if (generation !== this.deriveGeneration) {
+ return null;
+ }
+
+ this.form.patchValue({
+ deviceId1: derived?.deviceId1 ?? '',
+ deviceId2: derived?.deviceId2 ?? '',
+ });
+
+ return derived;
+ }
+
clearForm(): void {
+ // Same hazard as unticking the box: a digest still in flight would
+ // resolve into the freshly cleared form.
+ this.invalidatePendingDerivation();
+ this.derivesDeviceIds.set(false);
+ this.form.controls.deviceId1.enable();
+ this.form.controls.deviceId2.enable();
this.form.reset({
_id: createRandomId(),
title: '',
@@ -113,14 +324,37 @@ export class StalkerPortalImportComponent {
}
this.isLoading.set(true);
+ // The identity is frozen for the duration: an edit made now cannot
+ // reach the portal (discovery has the snapshot) and cannot be undone
+ // on it either (`get_profile` pins what it was sent), so the fields
+ // must not invite one.
+ this.form.disable({ emitEvent: false });
try {
+ // Before anything reads the form: clicking Add blurs the MAC
+ // field, so a derivation may still be in flight, and the pairing
+ // this produces is the one the portal pins forever. The MAC and
+ // the device IDs come back TOGETHER from one read — taking them
+ // from `getRawValue()` below would let an edit that landed during
+ // the digest pair a new MAC with the old MAC's IDs.
+ const identity = await this.settleMacAddressIdentity();
+ const macAddress = identity.macAddress;
+
+ // Authoritative for the rest of the import, and deliberately so:
+ // discovery below authenticates with exactly these values, and
+ // `get_profile` is what makes the portal pin them to the MAC.
+ // Re-reading the form after discovery — to pick up an edit made
+ // while it was running — would persist device IDs that differ
+ // from the ones already pinned, or none at all, and sending
+ // nothing after a value was pinned is the permanent lockout. The
+ // identity controls are locked while `isLoading()` so the UI
+ // cannot suggest otherwise.
const formValue = this.form.getRawValue();
const originalUrl = formValue.portalUrl ?? '';
const stalkerIdentity = normalizeStalkerPortalIdentity({
serialNumber: formValue.serialNumber ?? undefined,
- deviceId1: formValue.deviceId1 ?? undefined,
- deviceId2: formValue.deviceId2 ?? undefined,
+ deviceId1: identity.deviceId1 || undefined,
+ deviceId2: identity.deviceId2 || undefined,
signature1: formValue.signature1 ?? undefined,
signature2: formValue.signature2 ?? undefined,
});
@@ -132,7 +366,7 @@ export class StalkerPortalImportComponent {
// rewrote `…/c` to a `portal.php` official Ministra never serves.
const discovery = await this.portalDiscovery.discover(
originalUrl,
- formValue.macAddress ?? '',
+ macAddress,
stalkerIdentity,
{
credentials: {
@@ -242,6 +476,9 @@ export class StalkerPortalImportComponent {
const playlist: Playlist = {
...playlistFormValue,
+ // Canonical form, so the stored MAC is the one that was
+ // validated against the portal a moment ago.
+ macAddress,
portalUrl,
isFullStalkerPortal,
stalkerToken,
@@ -255,22 +492,32 @@ export class StalkerPortalImportComponent {
? {
stalkerSessionIdentity: stalkerSessionFingerprint({
portalUrl,
- macAddress: formValue.macAddress ?? '',
+ macAddress,
username: formValue.username ?? '',
password: formValue.password ?? '',
- ...this.toPlaylistIdentityFields(stalkerIdentity),
+ ...toStalkerPlaylistIdentityFields(
+ stalkerIdentity
+ ),
} as Playlist),
}
: {}),
stalkerWatchdogTimeout,
stalkerTimeslot,
stalkerAccountInfo,
- ...this.toPlaylistIdentityFields(stalkerIdentity),
+ ...toStalkerPlaylistIdentityFields(stalkerIdentity),
} as Playlist;
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
this.addClicked.emit();
} finally {
+ this.form.enable({ emitEvent: false });
+ // `enable()` clears the derivation toggle's own lock on the
+ // device ID controls, so restore it for a form the user stays on
+ // after a refused import.
+ if (this.derivesDeviceIds()) {
+ this.form.controls.deviceId1.disable({ emitEvent: false });
+ this.form.controls.deviceId2.disable({ emitEvent: false });
+ }
this.isLoading.set(false);
}
}
@@ -282,15 +529,9 @@ export class StalkerPortalImportComponent {
*/
private buildAuthErrorMessage(error: unknown): string {
const portalError = asStalkerPortalError(error);
- const keyByKind: Record = {
- 'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
- 'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
- blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
- 'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
- };
const base = this.translate.instant(
portalError
- ? keyByKind[portalError.kind]
+ ? STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind]
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
);
@@ -304,31 +545,4 @@ export class StalkerPortalImportComponent {
return base;
}
-
- private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
- stalkerSerialNumber?: string;
- stalkerDeviceId1?: string;
- stalkerDeviceId2?: string;
- stalkerSignature1?: string;
- stalkerSignature2?: string;
- } {
- return {
- ...(identity.serialNumber
- ? { stalkerSerialNumber: identity.serialNumber }
- : {}),
- ...(identity.deviceId1
- ? { stalkerDeviceId1: identity.deviceId1 }
- : {}),
- ...(identity.deviceId2
- ? { stalkerDeviceId2: identity.deviceId2 }
- : {}),
- ...(identity.signature1
- ? { stalkerSignature1: identity.signature1 }
- : {}),
- ...(identity.signature2
- ? { stalkerSignature2: identity.signature2 }
- : {}),
- };
- }
-
}
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html
index 6df821c86..60b840f47 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html
@@ -21,14 +21,29 @@
}
@if (playlist.macAddress) {
-
+ {{
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
}}
-
+
+ {{
+ 'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
+ }}
}
@if (playlist.portalUrl) {
+ @if (hasStoredStalkerDeviceIds) {
+
+ }
{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts
index dcd1afd69..6e8fec1b2 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts
@@ -12,7 +12,7 @@ import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
-import { Playlist } from '@iptvnator/shared/interfaces';
+import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistInfoComponent } from './playlist-info.component';
describe('PlaylistInfoComponent', () => {
@@ -566,6 +566,222 @@ describe('PlaylistInfoComponent', () => {
);
});
+ describe('Stalker identity fields', () => {
+ function createStalkerComponent(
+ overrides: Partial = {}
+ ): void {
+ TestBed.overrideProvider(MAT_DIALOG_DATA, {
+ useValue: {
+ ...playlist,
+ url: undefined,
+ portalUrl: 'https://portal.example.com/c',
+ macAddress: '00:1a:79:aa:bb:cc',
+ isFullStalkerPortal: true,
+ ...overrides,
+ } as Playlist & { id: string },
+ });
+ createComponent();
+ fixture.detectChanges();
+ }
+
+ it('canonicalizes an edited MAC on blur', () => {
+ createStalkerComponent();
+ const control = component.playlistDetails.get('macAddress');
+ control?.setValue('00-1a-79-ab-cd-ef');
+
+ component.onMacAddressBlur();
+
+ expect(control?.value).toBe('00:1A:79:AB:CD:EF');
+ // The rewrite is a change the user has to save deliberately.
+ expect(control?.dirty).toBe(true);
+ });
+
+ it('leaves a stored MAC untouched until it is edited', () => {
+ // Loading the dialog must not move the session fingerprint: the
+ // stored lowercase MAC is what the portal already accepted.
+ createStalkerComponent();
+
+ expect(component.playlistDetails.get('macAddress')?.value).toBe(
+ '00:1a:79:aa:bb:cc'
+ );
+ expect(component.playlistDetails.pristine).toBe(true);
+ });
+
+ it('refuses to save a malformed MAC', () => {
+ createStalkerComponent();
+ const control = component.playlistDetails.get('macAddress');
+
+ control?.setValue('00:1A:79:AA:BB');
+
+ expect(control?.valid).toBe(false);
+ expect(component.playlistDetails.valid).toBe(false);
+ });
+
+ it('canonicalizes the MAC on submit when no blur fired', async () => {
+ // Pressing Enter inside the field submits without the field losing
+ // focus, so `onMacAddressBlur` never runs.
+ createStalkerComponent();
+ component.playlistDetails.get('macAddress')?.setValue(
+ '00-1a-79-ab-cd-ef'
+ );
+
+ await component.saveChanges(
+ component.playlistDetails.value as PlaylistMeta
+ );
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ PlaylistActions.updatePlaylistMeta({
+ playlist: expect.objectContaining({
+ macAddress: '00:1A:79:AB:CD:EF',
+ }) as PlaylistMeta,
+ })
+ );
+ });
+
+ it('persists a grandfathered MAC untouched on submit', async () => {
+ createStalkerComponent({ macAddress: 'legacy-device-42' });
+
+ await component.saveChanges(
+ component.playlistDetails.value as PlaylistMeta
+ );
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ PlaylistActions.updatePlaylistMeta({
+ playlist: expect.objectContaining({
+ macAddress: 'legacy-device-42',
+ }) as PlaylistMeta,
+ })
+ );
+ });
+
+ it('leaves a non-canonical MAC alone when focus passes through it', async () => {
+ // Tabbing through the dialog fires blur with no edit. Rewriting
+ // there would mark the form dirty AND make the value differ from
+ // the stored one, which is what the submit guard reads — so a
+ // later title-only save would carry the rewritten identity.
+ createStalkerComponent({ macAddress: '00-1a-79-aa-bb-cc' });
+ const control = component.playlistDetails.get('macAddress');
+
+ component.onMacAddressBlur();
+
+ expect(control?.value).toBe('00-1a-79-aa-bb-cc');
+ expect(control?.dirty).toBe(false);
+
+ component.playlistDetails.get('title')?.setValue('Renamed');
+ await component.saveChanges(
+ component.playlistDetails.value as PlaylistMeta
+ );
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ PlaylistActions.updatePlaylistMeta({
+ playlist: expect.objectContaining({
+ macAddress: '00-1a-79-aa-bb-cc',
+ }) as PlaylistMeta,
+ })
+ );
+ });
+
+ it('leaves an untouched non-canonical MAC alone on an unrelated save', async () => {
+ // Renaming a playlist must not rewrite its MAC: those bytes are
+ // what a permissive portal registered, and changing them moves
+ // the session fingerprint and re-authenticates under a spelling
+ // the portal never saw.
+ createStalkerComponent({ macAddress: '00-1a-79-aa-bb-cc' });
+ component.playlistDetails.get('title')?.setValue('Renamed');
+
+ await component.saveChanges(
+ component.playlistDetails.value as PlaylistMeta
+ );
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ PlaylistActions.updatePlaylistMeta({
+ playlist: expect.objectContaining({
+ macAddress: '00-1a-79-aa-bb-cc',
+ title: 'Renamed',
+ }) as PlaylistMeta,
+ })
+ );
+ });
+
+ it('does not claim a simple portal has pinned its device IDs', () => {
+ // device_id travels only on get_profile/do_auth, which a
+ // panel-style portal never runs — so nothing was pinned and the
+ // lockout warning would be false.
+ createStalkerComponent({
+ isFullStalkerPortal: false,
+ stalkerDeviceId1: 'ABCDEF',
+ });
+
+ expect(component.hasStoredStalkerDeviceIds).toBe(false);
+ expect(fixture.nativeElement.textContent).not.toContain(
+ 'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
+ );
+ });
+
+ it('keeps a MAC outside the Infomir range saveable', () => {
+ // Most reseller panels do not run the stock OUI filter, so this is
+ // a working configuration — the import hint explains the risk, the
+ // form must not block it.
+ createStalkerComponent({ macAddress: 'AA:BB:CC:DD:EE:01' });
+
+ expect(component.playlistDetails.get('macAddress')?.valid).toBe(
+ true
+ );
+ });
+
+ it('grandfathers a stored MAC it would now reject', () => {
+ // Before this validation existed the field accepted anything, and
+ // on a panel that ignores the MAC such a playlist works. Blocking
+ // Save would also strand the title, URL and EPG edits in the same
+ // dialog.
+ createStalkerComponent({ macAddress: 'legacy-device-42' });
+
+ expect(component.playlistDetails.get('macAddress')?.valid).toBe(
+ true
+ );
+ expect(component.playlistDetails.valid).toBe(true);
+ });
+
+ it('still refuses a newly typed malformed MAC on a grandfathered playlist', () => {
+ createStalkerComponent({ macAddress: 'legacy-device-42' });
+ const control = component.playlistDetails.get('macAddress');
+
+ control?.setValue('legacy-device-43');
+
+ expect(control?.valid).toBe(false);
+ });
+
+ it('does not warn about a pinning that has not happened', () => {
+ createStalkerComponent();
+
+ expect(component.hasStoredStalkerDeviceIds).toBe(false);
+ expect(fixture.nativeElement.textContent).not.toContain(
+ 'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
+ );
+ });
+
+ it('treats a blank stored device ID as never sent', () => {
+ createStalkerComponent({ stalkerDeviceId2: ' ' });
+
+ expect(component.hasStoredStalkerDeviceIds).toBe(false);
+ });
+
+ it('warns once a device ID has been pinned', () => {
+ createStalkerComponent({ stalkerDeviceId1: 'ABCDEF' });
+
+ expect(component.hasStoredStalkerDeviceIds).toBe(true);
+ expect(fixture.nativeElement.textContent).toContain(
+ 'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
+ );
+ });
+
+ it('warns when only the second device ID is pinned', () => {
+ createStalkerComponent({ stalkerDeviceId2: 'FEDCBA' });
+
+ expect(component.hasStoredStalkerDeviceIds).toBe(true);
+ });
+ });
+
it('falls back to browser download when desktop file saving is unavailable', async () => {
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts
index 3d0882346..e4431b2ed 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts
@@ -32,6 +32,9 @@ import {
SettingsStore,
} from '@iptvnator/services';
import {
+ createStalkerMacAddressValidator,
+ normalizeStalkerIdentityValue,
+ normalizeStalkerMacAddress,
normalizeXtreamServerUrl,
Playlist,
PlaylistMeta,
@@ -82,6 +85,10 @@ const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/;
line-height: 1.45;
}
+ mat-dialog-content p.stalker-device-id-warning {
+ color: var(--mat-sys-error);
+ }
+
.playlist-epg-sources {
display: flex;
flex-direction: column;
@@ -195,6 +202,63 @@ export class PlaylistInfoComponent {
return !this.playlist.serverUrl && !this.playlist.macAddress;
}
+ /**
+ * True once a device ID has actually reached the portal, which is the
+ * point of no return: the stock server pins the first non-empty
+ * `device_id`/`device_id2` to the MAC permanently, refuses a different one
+ * as a device conflict, and treats a later empty value as a lockout. The
+ * fields stay editable — a value that was never accepted may well need
+ * correcting — but the consequence has to be on screen.
+ *
+ * Storage is not transmission, so `isFullStalkerPortal` gates it.
+ * `device_id` travels only on `get_profile`/`do_auth`, which simple
+ * panel-style portals never run; the import's offline fallback also
+ * persists whatever was typed and records the playlist as simple. Warning
+ * those users that a change "will lock this source out" would be false,
+ * and would discourage them from fixing an ID that was never pinned.
+ */
+ get hasStoredStalkerDeviceIds(): boolean {
+ if (!this.playlist.isFullStalkerPortal) {
+ return false;
+ }
+
+ return Boolean(
+ normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId1) ??
+ normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId2)
+ );
+ }
+
+ /**
+ * Canonicalizes an edited MAC on blur, so the stored value is the one the
+ * portal's own format check accepts.
+ *
+ * Only an EDIT normalizes it. Merely focusing the field and tabbing on
+ * must leave it alone: rewriting it there would mark the form dirty and
+ * make the value differ from the stored one, which is exactly what the
+ * submit-path guard reads — so a later title-only save would carry the
+ * rewritten identity through and move the session fingerprint without the
+ * user having touched the MAC at all.
+ */
+ onMacAddressBlur(): void {
+ const control = this.playlistDetails.get('macAddress');
+
+ if (!control || !this.isStalkerMacAddressEdited(control.value)) {
+ return;
+ }
+
+ const normalized = normalizeStalkerMacAddress(control.value);
+
+ if (normalized && normalized !== control.value) {
+ control.setValue(normalized);
+ control.markAsDirty();
+ }
+ }
+
+ /** Whether a MAC value differs from the one the playlist was loaded with. */
+ private isStalkerMacAddressEdited(value: unknown): boolean {
+ return value !== this.playlist.macAddress;
+ }
+
get playlistEpgSourceInputs(): UntypedFormArray {
return this.playlistDetails.get(
'playlistEpgSourceInputs'
@@ -244,7 +308,14 @@ export class PlaylistInfoComponent {
serverUrl: new FormControl(this.playlist.serverUrl),
username: new FormControl(this.playlist.username),
password: new FormControl(this.playlist.password),
- macAddress: new FormControl(this.playlist.macAddress),
+ macAddress: new FormControl(
+ this.playlist.macAddress,
+ // Grandfathered: a playlist stored before this validation
+ // existed may hold anything, and on a panel that ignores the
+ // MAC it works. Blocking Save over it would strand the user's
+ // title/URL/EPG edits too.
+ createStalkerMacAddressValidator(this.playlist.macAddress)
+ ),
portalUrl: new FormControl(this.playlist.portalUrl),
stalkerSerialNumber: new FormControl(
this.playlist.stalkerSerialNumber
@@ -261,8 +332,9 @@ export class PlaylistInfoComponent {
async saveChanges(playlist: PlaylistMeta): Promise {
try {
- const normalizedPlaylist =
- this.normalizeXtreamPlaylistMeta(playlist);
+ const normalizedPlaylist = this.normalizeStalkerPlaylistMeta(
+ this.normalizeXtreamPlaylistMeta(playlist)
+ );
const isXtream =
this.playlist &&
this.playlist.username &&
@@ -300,6 +372,38 @@ export class PlaylistInfoComponent {
}
}
+ /**
+ * Canonicalizes the MAC on the submit path as well as on blur. Pressing
+ * Enter inside the field submits the dialog without the field losing
+ * focus, so the blur handler never runs and the raw `00-1a-79-…` the user
+ * typed would be persisted and sent to a portal whose format check
+ * refuses it.
+ *
+ * Only an ACTUAL edit is normalized. A MAC the user never touched is
+ * passed through byte for byte, even when it is non-canonical: those
+ * bytes are what a permissive portal registered, and rewriting them
+ * because someone renamed the playlist would move the session
+ * fingerprint and re-authenticate under a spelling the portal never saw.
+ * That is the same reason a stored MAC is not rewritten on load.
+ *
+ * A value that does not parse is left alone too — the grandfathered case,
+ * where a playlist stored before this validation existed may be working
+ * on a panel that ignores the MAC entirely.
+ */
+ private normalizeStalkerPlaylistMeta(playlist: PlaylistMeta): PlaylistMeta {
+ if (!this.isStalkerMacAddressEdited(playlist.macAddress)) {
+ return playlist;
+ }
+
+ const normalizedMac = normalizeStalkerMacAddress(playlist.macAddress);
+
+ if (!normalizedMac || normalizedMac === playlist.macAddress) {
+ return playlist;
+ }
+
+ return { ...playlist, macAddress: normalizedMac };
+ }
+
private normalizeXtreamPlaylistMeta(playlist: PlaylistMeta): PlaylistMeta {
if (!playlist.serverUrl || !playlist.username || !playlist.password) {
return playlist;
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts
index 43b31b6a2..075977510 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts
@@ -48,6 +48,49 @@ describe('StalkerAuthApi', () => {
);
}
+ it('reports one coherent MAG250 in the profile request', async () => {
+ sendIpcEvent
+ .mockResolvedValueOnce({ js: { token: 'TOKEN-1', random: 'r1' } })
+ .mockResolvedValueOnce({ js: { status: 0 } });
+
+ await api.authenticate(portalUrl, macAddress);
+
+ const [profile] = callsByAction('get_profile');
+ expect(profile[1].params).toEqual(
+ expect.objectContaining({
+ // `stb_type` used to go out as an empty string.
+ stb_type: 'MAG250',
+ ver: expect.stringContaining('0.2.18-r14-pub-250'),
+ hw_version: '1.7-BD-00',
+ image_version: '218',
+ client_type: 'STB',
+ num_banks: '2',
+ video_out: 'hdmi',
+ hd: '1',
+ })
+ );
+ // Same box as the metrics payload and the MAG User-Agent header.
+ expect(JSON.parse(profile[1].params.metrics).model).toBe('MAG250');
+ });
+
+ it('keeps the box description out of the flow-control params', async () => {
+ // The constants are spread first, so a name collision would let them
+ // silently overwrite a computed value.
+ sendIpcEvent
+ .mockResolvedValueOnce({
+ js: { token: 'TOKEN-1', random: 'r1', not_valid: 1 },
+ })
+ .mockResolvedValueOnce({ js: { status: 0 } });
+
+ await api.authenticate(portalUrl, macAddress);
+
+ const [profile] = callsByAction('get_profile');
+ expect(profile[1].params.not_valid_token).toBe('1');
+ expect(profile[1].params.auth_second_step).toBe('0');
+ expect(profile[1].params.action).toBe('get_profile');
+ expect(profile[1].params.type).toBe('stb');
+ });
+
it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => {
sendIpcEvent
.mockResolvedValueOnce({
@@ -188,7 +231,7 @@ describe('StalkerAuthApi', () => {
});
});
- it('decodes a blocked profile into the portal explanation', async () => {
+ it('decodes a device conflict into its own kind', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
@@ -204,12 +247,36 @@ describe('StalkerAuthApi', () => {
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
- kind: 'blocked',
+ // Not `blocked`: this refusal has a remedy, and the portal's own
+ // "STB is damaged" wording actively points away from it.
+ kind: 'device-conflict',
portalText:
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
});
});
+ it('decodes any other blocked profile into the portal explanation', async () => {
+ sendIpcEvent
+ .mockResolvedValueOnce({
+ js: { token: 'TOKEN-1', random: 'r1' },
+ })
+ .mockResolvedValueOnce({
+ js: {
+ status: 1,
+ msg: 'Account disabled',
+ block_msg: 'Contact your provider. Subscription ended.',
+ },
+ });
+
+ await expect(
+ api.authenticate(portalUrl, macAddress)
+ ).rejects.toMatchObject({
+ kind: 'blocked',
+ portalText:
+ 'Account disabled — Contact your provider. Subscription ended.',
+ });
+ });
+
it('treats a bare {status:1} profile as refused', async () => {
sendIpcEvent
.mockResolvedValueOnce({
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts
index fa5f6e020..fc40a245e 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts
@@ -2,6 +2,7 @@ import type { DataService } from '@iptvnator/services';
import {
extractStalkerAuthFailureBody,
STALKER_REQUEST,
+ STALKER_STB_PROFILE_PARAMS,
} from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
import {
@@ -10,6 +11,7 @@ import {
} from './stalker-identity.utils';
import {
combineStalkerPortalMessages,
+ isStalkerDeviceConflictMessage,
StalkerPortalError,
} from './stalker-portal-error';
@@ -278,11 +280,12 @@ export class StalkerAuthApi {
const params: Record = {
type: 'stb',
action: 'get_profile',
- hd: '1',
+ // One coherent MAG250: firmware, hardware revision, image version
+ // and `stb_type` (which used to go out empty), alongside the
+ // `hd`/`video_out`/`num_banks` this request already carried.
+ ...STALKER_STB_PROFILE_PARAMS,
not_valid_token: options.notValidToken ? '1' : '0',
- video_out: 'hdmi',
auth_second_step: options.authSecondStep ? '1' : '0',
- num_banks: '2',
metrics: JSON.stringify(metrics),
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
@@ -300,7 +303,6 @@ export class StalkerAuthApi {
? { signature2: normalizedIdentity.signature2 }
: {}),
prehash: prehash,
- stb_type: '',
JsHttpRequest: '1-xml',
};
@@ -538,7 +540,15 @@ export class StalkerAuthApi {
if (toFiniteNumber(js?.status) === 1 || portalText) {
this.logger.error('Profile error:', portalText ?? 'status 1');
- throw new StalkerPortalError('blocked', portalText);
+ // A device conflict is the one refusal the user can act on, and
+ // the portal's own wording for it ("Your STB is damaged") points
+ // at the wrong problem entirely.
+ throw new StalkerPortalError(
+ isStalkerDeviceConflictMessage(portalText)
+ ? 'device-conflict'
+ : 'blocked',
+ portalText
+ );
}
return settled;
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts
index f09ea54fd..3f9372cde 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts
@@ -1,6 +1,7 @@
import {
asStalkerPortalError,
combineStalkerPortalMessages,
+ isStalkerDeviceConflictMessage,
StalkerPortalError,
stripStalkerPortalMarkup,
} from './stalker-portal-error';
@@ -32,6 +33,47 @@ describe('combineStalkerPortalMessages', () => {
});
});
+describe('isStalkerDeviceConflictMessage', () => {
+ it.each([
+ // What the stock middleware and the mock server actually send.
+ 'device conflict - device_id mismatch',
+ 'device conflict - MAC address mismatch',
+ 'Device Conflict',
+ 'device_id mismatch',
+ 'device id does not match the registered one',
+ ])('recognizes %p', (message) => {
+ expect(isStalkerDeviceConflictMessage(message)).toBe(true);
+ });
+
+ it.each([
+ // Other status-1 refusals that must keep the generic `blocked`
+ // headline — offering "restore your first device ID" for any of
+ // these would send the user down a dead end.
+ 'Account disabled',
+ 'Your subscription has expired',
+ 'Device limit reached',
+ 'No device selected',
+ 'Your STB is damaged. Call the provider.',
+ ])('does not claim %p is a device conflict', (message) => {
+ expect(isStalkerDeviceConflictMessage(message)).toBe(false);
+ });
+
+ it('is false without portal text', () => {
+ expect(isStalkerDeviceConflictMessage(undefined)).toBe(false);
+ expect(isStalkerDeviceConflictMessage('')).toBe(false);
+ });
+
+ it('does not bridge sentence boundaries', () => {
+ // "device_id" in one sentence and "mismatch" in the next are two
+ // unrelated statements.
+ expect(
+ isStalkerDeviceConflictMessage(
+ 'Your device_id is recorded. A password mismatch was logged.'
+ )
+ ).toBe(false);
+ });
+});
+
describe('asStalkerPortalError', () => {
it('recognizes real instances', () => {
const error = new StalkerPortalError('blocked', 'text');
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts
index d457b834d..f3e4e825c 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts
@@ -8,8 +8,13 @@ import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
* - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing
* script refused the credentials), or the profile still demanded a login
* after a successful `do_auth`.
- * - `blocked` — `get_profile` answered `status: 1`: the account is blocked or
- * the device identity conflicts. `msg`/`block_msg` explain why.
+ * - `device-conflict` — a `status: 1` refusal whose `msg` names the device
+ * binding: the portal already pinned a different `device_id`/`device_id2` to
+ * this MAC. Split out of `blocked` because it is the one refusal with a
+ * concrete remedy, and because the portal's own words for it ("Your STB is
+ * damaged") describe hardware failure rather than what actually happened.
+ * - `blocked` — any other `get_profile` `status: 1`: the account is disabled,
+ * the MAC is unknown or malformed. `msg`/`block_msg` explain why.
* - `auth-failed` — a request came back as one of the plain-text bodies
* (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and
* re-authentication did not recover it.
@@ -17,9 +22,40 @@ import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
export type StalkerPortalErrorKind =
| 'login-required'
| 'login-rejected'
+ | 'device-conflict'
| 'blocked'
| 'auth-failed';
+/**
+ * Device-conflict phrasings seen in the wild, matched against the portal's
+ * `msg`/`block_msg` — a STRUCTURED field the middleware wrote, so a phrase set
+ * is safe here in a way it would not be against a raw HTML body.
+ *
+ * Kept to the binding itself: "device" alone appears in unrelated refusals
+ * ("device limit reached", "no device selected"), and mislabelling one of
+ * those would hand the user a remedy that cannot work.
+ */
+const DEVICE_CONFLICT_PATTERNS: readonly RegExp[] = [
+ /device\s*conflict/i,
+ /device[\s_-]?id[^.!?]{0,40}?(mismatch|conflict|does\s*not\s*match|not\s*match)/i,
+];
+
+/**
+ * True when a `status: 1` refusal is the portal reporting that this MAC is
+ * already bound to a different device ID.
+ */
+export function isStalkerDeviceConflictMessage(
+ portalText: string | undefined
+): boolean {
+ if (!portalText) {
+ return false;
+ }
+
+ return DEVICE_CONFLICT_PATTERNS.some((pattern) =>
+ pattern.test(portalText)
+ );
+}
+
/**
* `block_msg` routinely carries markup ("Your STB is damaged. Call the
* provider."); strip it before the text reaches a snackbar or error view.
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts
index 2b2846b64..86e53d0e6 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts
@@ -1288,7 +1288,7 @@ describe('StalkerSessionService identity payloads', () => {
});
it('throws StalkerPortalError with the portal text when auth is refused', async () => {
- // Blocked account: get_profile answers status 1 with msg/block_msg.
+ // Device conflict: get_profile answers status 1 with msg/block_msg.
dataService.sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'token-1', random: 'random-1' },
@@ -1305,7 +1305,7 @@ describe('StalkerSessionService identity payloads', () => {
service.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
name: 'StalkerPortalError',
- kind: 'blocked',
+ kind: 'device-conflict',
portalText:
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
});
diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts
index 411041f00..bcd0511e7 100644
--- a/libs/shared/interfaces/src/index.ts
+++ b/libs/shared/interfaces/src/index.ts
@@ -71,7 +71,9 @@ export * from './lib/xtream-vod-stream.interface';
export * from './lib/stalker-item.normalizer';
export * from './lib/stalker-item-tmdb-hints';
export * from './lib/stalker-identity.utils';
+export * from './lib/stalker-mac-address.util';
export * from './lib/stalker-request-identity.util';
+export * from './lib/stalker-stb-profile.const';
export * from './lib/stalker-request-url.util';
export * from './lib/stalker-portal-item.interface';
export * from './lib/stalker-stream-profile.util';
diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts
index f16e5a709..fecdaf60a 100644
--- a/libs/shared/interfaces/src/lib/playlist.interface.ts
+++ b/libs/shared/interfaces/src/lib/playlist.interface.ts
@@ -77,9 +77,16 @@ export interface Playlist {
stalkerTimeslot?: number;
/** Serial number for stalker portal - generated once and stored for consistency */
stalkerSerialNumber?: string;
- /** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
+ /**
+ * Optional device ID 1 for stalker portal. Absent means absent — nothing
+ * generates one at request time. The import dialog can pre-fill a
+ * MAC-derived value on request, but it is stored here as a literal string
+ * from then on: the portal pins the first non-empty value to the MAC
+ * permanently, so a value that silently followed a later MAC edit would be
+ * refused as a device conflict.
+ */
stalkerDeviceId1?: string;
- /** Optional device ID 2 for stalker portal - if not provided, auto-generated from MAC */
+ /** Optional device ID 2 for stalker portal - same pinning rules as `stalkerDeviceId1`. */
stalkerDeviceId2?: string;
/** Optional signature 1 for stalker portal - required by some portals for device verification */
stalkerSignature1?: string;
diff --git a/libs/shared/interfaces/src/lib/stalker-identity.utils.spec.ts b/libs/shared/interfaces/src/lib/stalker-identity.utils.spec.ts
new file mode 100644
index 000000000..631286b27
--- /dev/null
+++ b/libs/shared/interfaces/src/lib/stalker-identity.utils.spec.ts
@@ -0,0 +1,73 @@
+import { deriveStalkerDeviceIdsFromMac } from './stalker-identity.utils';
+
+describe('deriveStalkerDeviceIdsFromMac', () => {
+ // Uppercase hex SHA-256 of the canonical MAC, and of that MAC plus the
+ // `stalker` salt — the values StbEmu and stalker-to-m3u pin server-side.
+ // Asserted literally: matching those clients byte for byte is the entire
+ // point of the option, so recomputing them with the implementation's own
+ // algorithm would assert nothing.
+ const EXPECTED = {
+ deviceId1:
+ 'A446559A63A6A489959198534E649760C6A9A6474DEE7C20314C2F1903B36422',
+ deviceId2:
+ 'BBD059367A90B0166654E6D4F9E09786CE64EB97674D1AF8D1EE2AE335D7205B',
+ };
+
+ it('derives the reference SHA-256 pair', async () => {
+ await expect(
+ deriveStalkerDeviceIdsFromMac('00:1A:79:AB:CD:EF')
+ ).resolves.toEqual(EXPECTED);
+ });
+
+ it('never produces an identical pair', async () => {
+ // A real box reports device_id and device_id2 from two separate
+ // firmware calls and they are never equal, so an identical pair is a
+ // fingerprint no STB produces — and the portal pins the first value it
+ // sees permanently, so it cannot be corrected afterwards.
+ const derived = await deriveStalkerDeviceIdsFromMac(
+ '00:1A:79:00:00:01'
+ );
+
+ expect(derived?.deviceId1).not.toBe(derived?.deviceId2);
+ });
+
+ it('hashes the canonical form, so input formatting cannot change the ids', async () => {
+ // A user who types the MAC with hyphens must not end up bound to
+ // different device ids than one who types colons — the portal pins the
+ // first values it sees, permanently.
+ await expect(
+ deriveStalkerDeviceIdsFromMac('00-1a-79-ab-cd-ef')
+ ).resolves.toEqual(EXPECTED);
+ await expect(
+ deriveStalkerDeviceIdsFromMac('001A79ABCDEF')
+ ).resolves.toEqual(EXPECTED);
+ });
+
+ it('produces 64 uppercase hex characters for both', async () => {
+ const derived = await deriveStalkerDeviceIdsFromMac('00:1A:79:00:00:01');
+
+ expect(derived?.deviceId1).toMatch(/^[0-9A-F]{64}$/);
+ expect(derived?.deviceId2).toMatch(/^[0-9A-F]{64}$/);
+ });
+
+ it('refuses to hash something that is not a MAC', async () => {
+ // Hashing a typo would pin the account to it forever.
+ await expect(
+ deriveStalkerDeviceIdsFromMac('00:1A:79')
+ ).resolves.toBeNull();
+ await expect(deriveStalkerDeviceIdsFromMac('')).resolves.toBeNull();
+ await expect(
+ deriveStalkerDeviceIdsFromMac(undefined)
+ ).resolves.toBeNull();
+ });
+
+ it('derives for a MAC outside the Infomir range', async () => {
+ // The OUI is a portal-side policy, not a precondition for hashing.
+ await expect(
+ deriveStalkerDeviceIdsFromMac('AA:BB:CC:DD:EE:01')
+ ).resolves.toEqual({
+ deviceId1: expect.stringMatching(/^[0-9A-F]{64}$/),
+ deviceId2: expect.stringMatching(/^[0-9A-F]{64}$/),
+ });
+ });
+});
diff --git a/libs/shared/interfaces/src/lib/stalker-identity.utils.ts b/libs/shared/interfaces/src/lib/stalker-identity.utils.ts
index 8acedaada..9cccc347f 100644
--- a/libs/shared/interfaces/src/lib/stalker-identity.utils.ts
+++ b/libs/shared/interfaces/src/lib/stalker-identity.utils.ts
@@ -1,3 +1,5 @@
+import { normalizeStalkerMacAddress } from './stalker-mac-address.util';
+
export const LEGACY_DEFAULT_STALKER_SERIAL = 'BEDACD4569BAF';
const STALKER_CFDUID_LENGTH = 32;
const STALKER_SERIAL_CFDUID_SUFFIX = 'e030245495acd6ebfc1';
@@ -49,6 +51,76 @@ export function normalizeStalkerPortalIdentity(
};
}
+/**
+ * Salt that separates `device_id2` from `device_id`, matching the
+ * `stalker-to-m3u` reference client.
+ */
+const STALKER_DEVICE_ID2_SALT = 'stalker';
+
+export interface StalkerDerivedDeviceIds {
+ deviceId1: string;
+ deviceId2: string;
+}
+
+async function sha256Upper(value: string): Promise {
+ const digest = await crypto.subtle.digest(
+ 'SHA-256',
+ new TextEncoder().encode(value)
+ );
+
+ return Array.from(new Uint8Array(digest))
+ .map((byte) => byte.toString(16).padStart(2, '0'))
+ .join('')
+ .toUpperCase();
+}
+
+/**
+ * Derives the MAC-based device ID pair that StbEmu and the `stalker-to-m3u`
+ * reference client generate: uppercase hex `SHA256` of the canonical
+ * `00:1A:79:…` MAC for `device_id`, and of that MAC plus a `stalker` salt for
+ * `device_id2`. A user who already reached the portal from one of those
+ * clients has these exact values pinned server-side, which is the only reason
+ * deriving them is useful at all.
+ *
+ * The two must differ. On a real box they come from separate firmware calls
+ * (`gSTB.GetUID()` and `gSTB.GetUID('device_id', token)`) and are never equal,
+ * so an identical pair is a fingerprint no STB produces — and since the first
+ * non-empty value is pinned to the MAC permanently, it cannot be corrected
+ * afterwards. They are derived together for that reason: nothing should be
+ * able to fill one without the other.
+ *
+ * This is a PREFILL helper, never a runtime fallback. `device_id`/`device_id2`
+ * are the one identity pair the stock server enforces: the first non-empty
+ * value it sees is bound to the MAC permanently, a later mismatch is refused
+ * as a device conflict, and going back to sending nothing locks the account
+ * out for good. So derived IDs are written into the form as literal values the
+ * user can see and edit, and persisted as literal strings — never recomputed
+ * behind their back, where a MAC edit would silently re-derive them into a
+ * conflict.
+ *
+ * Returns `null` whenever it cannot produce trustworthy IDs: when the MAC is
+ * not a valid address (hashing whatever happens to be in the field would bind
+ * the account to a typo, permanently), and when the runtime has no WebCrypto
+ * — an insecure-context PWA, where the handshake's own SHA-1 prehash cannot
+ * run either, so full-portal auth is already out of reach. Both cases fail
+ * closed: nothing is written, so nothing is pinned.
+ */
+export async function deriveStalkerDeviceIdsFromMac(
+ macAddress: string | null | undefined
+): Promise {
+ const normalizedMac = normalizeStalkerMacAddress(macAddress);
+ if (!normalizedMac || !globalThis.crypto?.subtle) {
+ return null;
+ }
+
+ const [deviceId1, deviceId2] = await Promise.all([
+ sha256Upper(normalizedMac),
+ sha256Upper(`${normalizedMac}${STALKER_DEVICE_ID2_SALT}`),
+ ]);
+
+ return { deviceId1, deviceId2 };
+}
+
export function buildStalkerSerialCfduid(serialNumber: string): string {
const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, '');
diff --git a/libs/shared/interfaces/src/lib/stalker-mac-address.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-mac-address.util.spec.ts
new file mode 100644
index 000000000..5f032f3ff
--- /dev/null
+++ b/libs/shared/interfaces/src/lib/stalker-mac-address.util.spec.ts
@@ -0,0 +1,131 @@
+import {
+ createStalkerMacAddressValidator,
+ hasInfomirMacOui,
+ INFOMIR_MAC_OUI,
+ normalizeStalkerMacAddress,
+ STALKER_MAC_ADDRESS_ERROR,
+ validateStalkerMacAddressControl,
+} from './stalker-mac-address.util';
+
+describe('normalizeStalkerMacAddress', () => {
+ it('upper-cases an already canonical address', () => {
+ expect(normalizeStalkerMacAddress('00:1a:79:ab:cd:ef')).toBe(
+ '00:1A:79:AB:CD:EF'
+ );
+ });
+
+ it.each([
+ ['hyphens', '00-1A-79-AB-CD-EF'],
+ ['dots', '001a.79ab.cdef'],
+ ['no separator', '001A79ABCDEF'],
+ ['surrounding whitespace', ' 00:1A:79:AB:CD:EF '],
+ ['embedded whitespace', '00 : 1A : 79 : AB : CD : EF'],
+ ])('accepts %s', (_label, input) => {
+ expect(normalizeStalkerMacAddress(input)).toBe('00:1A:79:AB:CD:EF');
+ });
+
+ it.each([
+ ['too short', '00:1A:79:AB:CD'],
+ ['too long', '00:1A:79:AB:CD:EF:01'],
+ ['non-hex digits', '00:1A:79:AB:CD:GG'],
+ ['empty', ''],
+ ['separators only', '::::::'],
+ ])('rejects %s', (_label, input) => {
+ expect(normalizeStalkerMacAddress(input)).toBeNull();
+ });
+
+ it.each([[null], [undefined]])('rejects %p', (input) => {
+ expect(normalizeStalkerMacAddress(input)).toBeNull();
+ });
+});
+
+describe('hasInfomirMacOui', () => {
+ it('accepts the Infomir range regardless of input formatting', () => {
+ expect(hasInfomirMacOui('001a79abcdef')).toBe(true);
+ expect(INFOMIR_MAC_OUI).toBe('00:1A:79');
+ });
+
+ it('reports a foreign OUI without rejecting the address', () => {
+ expect(hasInfomirMacOui('00:1B:79:AB:CD:EF')).toBe(false);
+ expect(normalizeStalkerMacAddress('00:1B:79:AB:CD:EF')).toBe(
+ '00:1B:79:AB:CD:EF'
+ );
+ });
+
+ it('is false for a malformed address', () => {
+ expect(hasInfomirMacOui('00:1A:79')).toBe(false);
+ });
+});
+
+describe('validateStalkerMacAddressControl', () => {
+ it('passes a valid address', () => {
+ expect(
+ validateStalkerMacAddressControl({ value: '00-1a-79-ab-cd-ef' })
+ ).toBeNull();
+ });
+
+ it('leaves emptiness to the required validator', () => {
+ expect(validateStalkerMacAddressControl({ value: '' })).toBeNull();
+ expect(validateStalkerMacAddressControl({ value: ' ' })).toBeNull();
+ expect(validateStalkerMacAddressControl({ value: null })).toBeNull();
+ });
+
+ it('reports a malformed address', () => {
+ expect(validateStalkerMacAddressControl({ value: 'not-a-mac' })).toEqual(
+ { [STALKER_MAC_ADDRESS_ERROR]: true }
+ );
+ });
+
+ it('accepts a MAC outside the Infomir range', () => {
+ // The stock portal's OUI filter is off on most reseller panels, so a
+ // non-Infomir MAC is a working configuration for a lot of users.
+ // Refusing it here would stop them adding or editing a portal that
+ // works today; `hasInfomirMacOui` only drives a hint.
+ expect(
+ validateStalkerMacAddressControl({ value: 'AA:BB:CC:DD:EE:01' })
+ ).toBeNull();
+ expect(hasInfomirMacOui('AA:BB:CC:DD:EE:01')).toBe(false);
+ });
+});
+
+describe('createStalkerMacAddressValidator', () => {
+ it('grandfathers the value a playlist already stored', () => {
+ // A playlist saved before this validation existed may hold anything,
+ // and on a panel that ignores the MAC it works. Marking the form
+ // invalid on open would disable Save and strand the user's title, URL
+ // and EPG edits over a field the portal may not even read.
+ const validate = createStalkerMacAddressValidator('legacy-device-42');
+
+ expect(validate({ value: 'legacy-device-42' })).toBeNull();
+ });
+
+ it('still refuses a newly typed malformed value', () => {
+ const validate = createStalkerMacAddressValidator('legacy-device-42');
+
+ expect(validate({ value: 'legacy-device-43' })).toEqual({
+ [STALKER_MAC_ADDRESS_ERROR]: true,
+ });
+ });
+
+ it('is the plain validator when there is nothing to grandfather', () => {
+ expect(
+ createStalkerMacAddressValidator(undefined)({ value: 'nope' })
+ ).toEqual({ [STALKER_MAC_ADDRESS_ERROR]: true });
+ expect(
+ createStalkerMacAddressValidator(null)({
+ value: '00:1A:79:AA:BB:CC',
+ })
+ ).toBeNull();
+ });
+
+ it('does not let an undefined exemption match an empty control', () => {
+ // `control.value === grandfatheredValue` would be true for two
+ // undefineds, which would exempt every untouched control.
+ const validate = createStalkerMacAddressValidator(undefined);
+
+ expect(validate({ value: undefined })).toBeNull();
+ expect(validate({ value: 'not-a-mac' })).toEqual({
+ [STALKER_MAC_ADDRESS_ERROR]: true,
+ });
+ });
+});
diff --git a/libs/shared/interfaces/src/lib/stalker-mac-address.util.ts b/libs/shared/interfaces/src/lib/stalker-mac-address.util.ts
new file mode 100644
index 000000000..bd07ee068
--- /dev/null
+++ b/libs/shared/interfaces/src/lib/stalker-mac-address.util.ts
@@ -0,0 +1,112 @@
+/**
+ * Infomir's OUI. The stock Stalker/Ministra MAC validator is enabled by
+ * default and only accepts addresses in this range
+ * (`/^00:1A:79:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$/`). A MAC outside it is
+ * refused with a bare `{status: 1}` and no explanation, which is
+ * indistinguishable from a blocked account — hence the hint in the import UI.
+ */
+export const INFOMIR_MAC_OUI = '00:1A:79';
+
+const MAC_SEPARATORS = /[\s:.-]/g;
+const TWELVE_HEX_DIGITS = /^[0-9A-F]{12}$/;
+
+/**
+ * Canonicalizes a MAC address to the uppercase colon form a real STB sends
+ * (`00:1A:79:12:34:56`), or returns `null` when the input is not a MAC.
+ *
+ * Accepts the shapes users actually paste — colons, hyphens, dots, embedded
+ * whitespace, or no separator at all — because a portal that validates the
+ * format answers a bare `{status: 1}`, and "your MAC has hyphens" is not a
+ * diagnosis anyone can make from that.
+ *
+ * Note this is an INPUT-boundary helper. Stored MAC addresses are deliberately
+ * not rewritten on read: the MAC is the account key, and silently changing the
+ * bytes an already-working playlist puts on the wire is exactly the kind of
+ * unattended identity change the Stalker pinning semantics punish. Normalizing
+ * what the user types (and only that) keeps the change visible and reversible.
+ */
+export function normalizeStalkerMacAddress(
+ value: string | null | undefined
+): string | null {
+ const digits = (value ?? '').replace(MAC_SEPARATORS, '').toUpperCase();
+
+ if (!TWELVE_HEX_DIGITS.test(digits)) {
+ return null;
+ }
+
+ return (digits.match(/.{2}/g) as string[]).join(':');
+}
+
+/**
+ * True when the address sits in Infomir's OUI, i.e. the stock portal's default
+ * MAC filter would accept it. A `false` verdict is a hint, never an error:
+ * plenty of resellers disable the check, and refusing to import would lock
+ * those users out of a portal that works.
+ */
+export function hasInfomirMacOui(value: string | null | undefined): boolean {
+ const normalized = normalizeStalkerMacAddress(value);
+
+ return normalized !== null && normalized.startsWith(`${INFOMIR_MAC_OUI}:`);
+}
+
+/** Error key an invalid MAC control reports. */
+export const STALKER_MAC_ADDRESS_ERROR = 'stalkerMacAddress';
+
+/**
+ * Form validator for a Stalker MAC field, shared by the import dialog and the
+ * playlist edit dialog so both accept exactly what `normalizeStalkerMacAddress`
+ * accepts.
+ *
+ * Deliberately structural rather than typed as Angular's `ValidatorFn`: this
+ * library is the contract layer the Electron main process imports, and it must
+ * stay free of framework dependencies. `AbstractControl` satisfies
+ * `{ value: unknown }`, so the function is assignable wherever a `ValidatorFn`
+ * is expected.
+ *
+ * An empty control is left alone — requiredness is a separate concern. The OUI
+ * is not checked at all: a MAC outside Infomir's range is refused by the stock
+ * portal but accepted by most reseller panels, so rejecting it here would lock
+ * users out of a portal that works for them (`hasInfomirMacOui` drives a hint
+ * instead).
+ */
+export function validateStalkerMacAddressControl(control: {
+ value: unknown;
+}): Record | null {
+ const value = control.value;
+
+ if (typeof value !== 'string' || value.trim() === '') {
+ return null;
+ }
+
+ return normalizeStalkerMacAddress(value)
+ ? null
+ : { [STALKER_MAC_ADDRESS_ERROR]: true };
+}
+
+/**
+ * Same validator, but one specific value is grandfathered in.
+ *
+ * The edit dialog needs this: before there was any validation a user could
+ * store an arbitrary string as the MAC, and on a panel that ignores the MAC
+ * entirely such a playlist works today. Attaching the plain validator there
+ * would mark the form invalid on open and disable Save — locking the user out
+ * of editing the title, the URL or the EPG sources of a working source,
+ * forever, over a field the portal may not even read.
+ *
+ * So the value that was already stored stays acceptable, while anything newly
+ * typed is held to the format. `grandfatheredValue` is compared verbatim: the
+ * exemption covers the string that is already on the wire, not a shape.
+ */
+export function createStalkerMacAddressValidator(
+ grandfatheredValue: string | null | undefined
+): (control: { value: unknown }) => Record<
+ typeof STALKER_MAC_ADDRESS_ERROR,
+ true
+> | null {
+ return (control) =>
+ grandfatheredValue !== undefined &&
+ grandfatheredValue !== null &&
+ control.value === grandfatheredValue
+ ? null
+ : validateStalkerMacAddressControl(control);
+}
diff --git a/libs/shared/interfaces/src/lib/stalker-stb-profile.const.ts b/libs/shared/interfaces/src/lib/stalker-stb-profile.const.ts
new file mode 100644
index 000000000..7831f848c
--- /dev/null
+++ b/libs/shared/interfaces/src/lib/stalker-stb-profile.const.ts
@@ -0,0 +1,33 @@
+/**
+ * The set-top box IPTVnator presents itself as. Every value here is a constant
+ * describing the emulated device, never anything derived from the user's
+ * account — the identity the portal binds to a MAC lives in `device_id` /
+ * `device_id2` and is handled separately.
+ *
+ * The stock Stalker middleware reads these on `get_profile` and stores them
+ * for the admin panel; only an operator's optional `access_filter.php` ever
+ * inspects them, and a box that reports nothing at all is the shape some of
+ * those filters reject. They are therefore free to send and worth sending —
+ * but only as one coherent MAG250: the model, firmware, hardware revision and
+ * image version have to describe the same box as `metrics.model` and the
+ * `STALKER_MAG_USER_AGENT` request header, or the profile reads as a forgery.
+ *
+ * These constants must not vary per playlist. They are deliberately excluded
+ * from `stalkerIdentityFingerprint` / `stalkerSessionFingerprint`: changing
+ * them would invalidate every persisted session for no gain, since the portal
+ * does not bind sessions to them.
+ */
+export const STALKER_STB_PROFILE_PARAMS: Readonly> =
+ Object.freeze({
+ /** Firmware banner a MAG250 reports verbatim. */
+ ver: 'ImageDescription: 0.2.18-r14-pub-250; ImageDate: Fri Jan 15 15:20:44 EET 2016; PORTAL version: 5.6.0; API Version: JS API version: 328; STB API version: 134; Player Engine version: 0x566',
+ /** Was sent empty before — some panels treat that as "not a box". */
+ stb_type: 'MAG250',
+ hw_version: '1.7-BD-00',
+ /** Numeric form of the `0.2.18` firmware in `ver`. */
+ image_version: '218',
+ client_type: 'STB',
+ num_banks: '2',
+ video_out: 'hdmi',
+ hd: '1',
+ });
diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts
index 2f20fe2e8..ae170ef4a 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts
@@ -5,7 +5,10 @@ import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
-import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
+import {
+ StalkerPortalError,
+ StalkerStore,
+} from '@iptvnator/portal/stalker/data-access';
import { WORKSPACE_CATEGORY_SORT_STORAGE_KEY } from '@iptvnator/portal/shared/util';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import {
@@ -558,4 +561,36 @@ describe('WorkspaceContextPanelComponent', () => {
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
+
+ describe('Stalker category error description', () => {
+ afterEach(() => {
+ stalkerStore.isCategoryResourceFailed.set(false as never);
+ });
+
+ it('leads with the remedy for a device conflict', () => {
+ // The portal blames the hardware ("Your STB is damaged"), which
+ // is the opposite of actionable — the explanation has to come
+ // first, with the portal's own words kept after it.
+ stalkerStore.isCategoryResourceFailed.set(
+ new StalkerPortalError(
+ 'device-conflict',
+ 'device conflict - device_id mismatch — Your STB is damaged.'
+ ) as never
+ );
+
+ expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe(
+ 'PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT device conflict - device_id mismatch — Your STB is damaged.'
+ );
+ });
+
+ it('still relays any other refusal verbatim', () => {
+ stalkerStore.isCategoryResourceFailed.set(
+ new StalkerPortalError('blocked', 'Account disabled') as never
+ );
+
+ expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe(
+ 'Account disabled'
+ );
+ });
+ });
});
diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts
index 102c124d0..8f7c9acbd 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts
@@ -165,6 +165,17 @@ export class WorkspaceContextPanelComponent {
const portalError = asStalkerPortalError(
this.isStalkerCategoryFailed()
);
+ // Device conflicts are the exception to "the portal explains itself":
+ // its own wording blames the hardware, so the actionable sentence
+ // leads and the portal's text follows it.
+ if (portalError?.kind === 'device-conflict') {
+ const hint = this.translate.instant(
+ 'PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT'
+ );
+ return portalError.portalText
+ ? `${hint} ${portalError.portalText}`
+ : hint;
+ }
if (portalError?.portalText) {
return portalError.portalText;
}