From d2d91e322b4f0733b5e5781bc4ad55fc2bad380a Mon Sep 17 00:00:00 2001
From: 4gray
Date: Sat, 8 Aug 2026 22:06:06 +0200
Subject: [PATCH] feat(stalker): discover portal connection on edit
---
.../src/stalker-portal-discovery.e2e.ts | 78 ++-
apps/web/src/app/app.config.ts | 6 +
...playlist-connection-editor.service.spec.ts | 359 ++++++++++
...lker-playlist-connection-editor.service.ts | 199 ++++++
apps/web/src/assets/i18n/ar.json | 4 +-
apps/web/src/assets/i18n/ary.json | 4 +-
apps/web/src/assets/i18n/by.json | 4 +-
apps/web/src/assets/i18n/de.json | 4 +-
apps/web/src/assets/i18n/el.json | 4 +-
apps/web/src/assets/i18n/en.json | 4 +-
apps/web/src/assets/i18n/es.json | 4 +-
apps/web/src/assets/i18n/fr.json | 4 +-
apps/web/src/assets/i18n/hu.json | 4 +-
apps/web/src/assets/i18n/it.json | 4 +-
apps/web/src/assets/i18n/ja.json | 4 +-
apps/web/src/assets/i18n/ko.json | 4 +-
apps/web/src/assets/i18n/nl.json | 4 +-
apps/web/src/assets/i18n/pl.json | 4 +-
apps/web/src/assets/i18n/pt.json | 4 +-
apps/web/src/assets/i18n/ru.json | 4 +-
apps/web/src/assets/i18n/tr.json | 4 +-
apps/web/src/assets/i18n/zh.json | 4 +-
apps/web/src/assets/i18n/zhtw.json | 4 +-
libs/m3u-state/src/lib/actions.ts | 3 +-
.../lib/reducers/playlist.reducers.spec.ts | 131 +++-
.../src/lib/reducers/playlist.reducers.ts | 39 +-
.../stalker-portal-import.component.html | 6 +-
.../stalker-portal-import.component.spec.ts | 59 +-
.../stalker-portal-import.component.ts | 13 +-
libs/playlist/shared/ui/src/index.ts | 1 +
.../playlist-info.component.html | 635 +++++++++---------
.../playlist-info.component.spec.ts | 198 +++++-
.../playlist-info/playlist-info.component.ts | 86 ++-
...talker-playlist-connection-editor.token.ts | 48 ++
.../stalker-playlist-edit.utils.ts | 68 ++
.../stalker-authenticated-request-client.ts | 87 +++
...stalker-edited-session-coordinator.spec.ts | 107 +++
.../lib/stalker-edited-session-coordinator.ts | 108 +++
.../stalker-portal-discovery.utils.spec.ts | 16 +-
.../src/lib/stalker-portal-discovery.utils.ts | 16 +-
.../lib/stalker-portal-repair.service.spec.ts | 87 ++-
.../src/lib/stalker-portal-repair.service.ts | 89 ++-
.../src/lib/stalker-session.service.spec.ts | 31 +-
.../src/lib/stalker-session.service.ts | 121 ++--
.../src/lib/playlists.service.spec.ts | 123 +++-
libs/services/src/lib/playlists.service.ts | 22 +-
.../interfaces/src/lib/playlist-meta.type.ts | 20 +
47 files changed, 2335 insertions(+), 497 deletions(-)
create mode 100644 apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts
create mode 100644 apps/web/src/app/services/stalker-playlist-connection-editor.service.ts
create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts
create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts
create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts
diff --git a/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts b/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts
index 6578220e3..820d6b0fb 100644
--- a/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts
+++ b/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts
@@ -4,12 +4,15 @@ import {
closeElectronApp,
expect,
launchElectronApp,
+ openSourceEditor,
openSources,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
stalkerMockServer,
test,
+ updateSourceDialog,
+ saveSourceDialog,
waitForStalkerCatalog,
} from './electron-test-fixtures';
@@ -34,6 +37,7 @@ import {
const CANONICAL_IMPORT_MAC = '00:1A:79:00:00:21';
const MINISTRA_IMPORT_MAC = '00:1A:79:00:00:22';
const RESELLER_IMPORT_MAC = '00:1A:79:00:00:23';
+const BARE_HOST_IMPORT_MAC = '00:1A:79:00:00:27';
const REPAIR_FLAG_MAC = '00:1A:79:00:00:24';
const REPAIR_ENDPOINT_MAC = '00:1A:79:00:00:25';
const HEALTHY_RESELLER_MAC = '00:1A:79:00:00:26';
@@ -82,22 +86,26 @@ async function seedStalkerPlaylist(
}
): Promise {
const nowIso = new Date().toISOString();
- await page.evaluate(async (playlist) => {
- const electron = (window as unknown as PlaylistStorageWindow).electron;
- await electron.dbUpsertAppPlaylist(playlist);
- }, {
- _id: row.id,
- title: row.title,
- macAddress: row.macAddress,
- portalUrl: row.portalUrl,
- isFullStalkerPortal: row.isFullStalkerPortal,
- count: 0,
- autoRefresh: false,
- importDate: nowIso,
- lastUsage: nowIso,
- favorites: [],
- recentlyViewed: [],
- });
+ await page.evaluate(
+ async (playlist) => {
+ const electron = (window as unknown as PlaylistStorageWindow)
+ .electron;
+ await electron.dbUpsertAppPlaylist(playlist);
+ },
+ {
+ _id: row.id,
+ title: row.title,
+ macAddress: row.macAddress,
+ portalUrl: row.portalUrl,
+ isFullStalkerPortal: row.isFullStalkerPortal,
+ count: 0,
+ autoRefresh: false,
+ importDate: nowIso,
+ lastUsage: nowIso,
+ favorites: [],
+ recentlyViewed: [],
+ }
+ );
}
async function openSeededPortal(page: Page, title: string): Promise {
@@ -106,7 +114,7 @@ async function openSeededPortal(page: Page, title: string): Promise {
await waitForStalkerCatalog(page);
}
-test('@electron @stalker import discovery resolves canonical, ministra-/c and reseller URLs', async ({
+test('@electron @stalker Add and Edit discover bare, canonical, Ministra and reseller URLs', async ({
dataDir,
request,
}) => {
@@ -167,6 +175,37 @@ test('@electron @stalker import discovery resolves canonical, ministra-/c and re
portalUrl: `${stalkerMockServer}/portal.php`,
isFullStalkerPortal: false,
});
+
+ // 4) A bare host is valid input. The reseller endpoint answers first,
+ // so Add persists the resolved API handler rather than root HTML.
+ await openSources(app.mainWindow);
+ await addStalkerPortal(app.mainWindow, {
+ name: 'Bare Host',
+ macAddress: BARE_HOST_IMPORT_MAC,
+ portalUrl: stalkerMockServer,
+ });
+ await waitForStalkerCatalog(app.mainWindow);
+ expect(
+ await readStoredPortalConfig(app.mainWindow, 'Bare Host')
+ ).toEqual({
+ portalUrl: `${stalkerMockServer}/portal.php`,
+ isFullStalkerPortal: false,
+ });
+
+ // Edit uses the same discovery path as Add. Moving the source to a
+ // genuine Ministra tenant must replace endpoint and mode together.
+ await openSources(app.mainWindow);
+ const editDialog = await openSourceEditor(app.mainWindow, 'Bare Host');
+ await updateSourceDialog(editDialog, {
+ portalUrl: `${stalkerMockServer}/ministra/c`,
+ });
+ await saveSourceDialog(app.mainWindow, editDialog);
+ await expect
+ .poll(() => readStoredPortalConfig(app.mainWindow, 'Bare Host'))
+ .toEqual({
+ portalUrl: `${stalkerMockServer}/ministra/server/load.php`,
+ isFullStalkerPortal: true,
+ });
} finally {
await closeElectronApp(app);
}
@@ -219,7 +258,10 @@ test('@electron @stalker lazy repair fixes misclassified stored portals and neve
await openSeededPortal(app.mainWindow, 'Misclassified Canonical');
await expect
.poll(() =>
- readStoredPortalConfig(app.mainWindow, 'Misclassified Canonical')
+ readStoredPortalConfig(
+ app.mainWindow,
+ 'Misclassified Canonical'
+ )
)
.toEqual({
portalUrl: `${stalkerMockServer}/server/load.php`,
diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts
index b5b45d06c..19fff0c3a 100644
--- a/apps/web/src/app/app.config.ts
+++ b/apps/web/src/app/app.config.ts
@@ -27,6 +27,7 @@ import {
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
+import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
@@ -42,6 +43,7 @@ import {
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
import { PwaService } from './services/pwa.service';
import { shouldEnableServiceWorker } from './services/runtime-config';
+import { AppStalkerPlaylistConnectionEditorService } from './services/stalker-playlist-connection-editor.service';
import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service';
// AoT requires an exported function for factories
@@ -152,6 +154,10 @@ export const appConfig: ApplicationConfig = {
provide: PLAYLIST_PLAYER_ACTIONS,
useExisting: AppPortalNavigationActionsService,
},
+ {
+ provide: STALKER_PLAYLIST_CONNECTION_EDITOR,
+ useExisting: AppStalkerPlaylistConnectionEditorService,
+ },
...provideWorkspaceShellActions(),
...provideXtreamDataSource(),
{
diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts
new file mode 100644
index 000000000..f2952dd85
--- /dev/null
+++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts
@@ -0,0 +1,359 @@
+import { TestBed } from '@angular/core/testing';
+import { TranslateService } from '@ngx-translate/core';
+import {
+ StalkerPortalDiscoveryService,
+ StalkerPortalError,
+ StalkerPortalRepairService,
+ StalkerSessionService,
+ StalkerStore,
+ stalkerSessionFingerprint,
+} from '@iptvnator/portal/stalker/data-access';
+import { STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS } from '@iptvnator/playlist/shared/ui';
+import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
+import { AppStalkerPlaylistConnectionEditorService } from './stalker-playlist-connection-editor.service';
+
+describe('AppStalkerPlaylistConnectionEditorService', () => {
+ const discovery = {
+ discover: jest.fn(),
+ };
+ const portalRepair = {
+ applyOverride: jest.fn((playlist: PlaylistMeta) => playlist),
+ retireForPlaylistEdit: jest.fn(),
+ };
+ const stalkerSession = {
+ replaceSessionAfterEdit: jest.fn().mockResolvedValue(undefined),
+ };
+ let activePlaylist: PlaylistMeta | undefined;
+ const stalkerStore = {
+ currentPlaylist: jest.fn(() => activePlaylist),
+ setCurrentPlaylist: jest.fn((playlist: PlaylistMeta) => {
+ activePlaylist = playlist;
+ }),
+ };
+ const translate = {
+ instant: jest.fn((key: string) => key),
+ };
+
+ const draft: PlaylistMeta = {
+ _id: 'stalker-1',
+ title: 'Stalker Portal',
+ count: 0,
+ importDate: '2026-08-08T00:00:00.000Z',
+ portalUrl: 'https://portal.example.com/c',
+ macAddress: '00:1A:79:AA:BB:CC',
+ username: 'subscriber',
+ password: 'secret',
+ stalkerSerialNumber: ' SERIAL ',
+ stalkerDeviceId1: ' DEVICE-1 ',
+ stalkerDeviceId2: '',
+ stalkerSignature1: ' SIGNATURE-1 ',
+ stalkerSignature2: '',
+ };
+
+ let service: AppStalkerPlaylistConnectionEditorService;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ activePlaylist = undefined;
+ TestBed.configureTestingModule({
+ providers: [
+ AppStalkerPlaylistConnectionEditorService,
+ {
+ provide: StalkerPortalDiscoveryService,
+ useValue: discovery,
+ },
+ {
+ provide: StalkerPortalRepairService,
+ useValue: portalRepair,
+ },
+ {
+ provide: StalkerSessionService,
+ useValue: stalkerSession,
+ },
+ { provide: StalkerStore, useValue: stalkerStore },
+ { provide: TranslateService, useValue: translate },
+ ],
+ });
+ service = TestBed.inject(AppStalkerPlaylistConnectionEditorService);
+ });
+
+ it('resolves a simple portal and clears the previous full session', async () => {
+ discovery.discover.mockResolvedValue({
+ status: 'resolved',
+ portalUrl: 'https://portal.example.com/portal.php',
+ isFullStalkerPortal: false,
+ });
+
+ const result = await service.resolveConnection(draft);
+
+ expect(discovery.discover).toHaveBeenCalledWith(
+ draft.portalUrl,
+ draft.macAddress,
+ {
+ serialNumber: 'SERIAL',
+ deviceId1: 'DEVICE-1',
+ signature1: 'SIGNATURE-1',
+ },
+ {
+ credentials: {
+ username: 'subscriber',
+ password: 'secret',
+ },
+ }
+ );
+ expect(result).toEqual({
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
+ playlist: {
+ ...draft,
+ portalUrl: 'https://portal.example.com/portal.php',
+ isFullStalkerPortal: false,
+ stalkerSerialNumber: 'SERIAL',
+ stalkerDeviceId1: 'DEVICE-1',
+ stalkerDeviceId2: '',
+ stalkerSignature1: 'SIGNATURE-1',
+ stalkerSignature2: '',
+ stalkerSessionPatch: null,
+ },
+ });
+ });
+
+ it('replaces a full-portal session with the confirmed authorization result', async () => {
+ discovery.discover.mockResolvedValue({
+ status: 'resolved',
+ portalUrl: 'https://portal.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ token: 'NEW_TOKEN',
+ watchdogTimeoutSeconds: 75,
+ timeslotSeconds: 8,
+ accountInfo: {
+ login: 'subscriber',
+ expire_date: 1800000000,
+ tariff_plan_name: 'Premium',
+ status: 0,
+ },
+ });
+
+ const result = await service.resolveConnection(draft);
+ const resolvedPlaylist = {
+ ...draft,
+ portalUrl: 'https://portal.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ stalkerSerialNumber: 'SERIAL',
+ stalkerDeviceId1: 'DEVICE-1',
+ stalkerDeviceId2: '',
+ stalkerSignature1: 'SIGNATURE-1',
+ stalkerSignature2: '',
+ };
+
+ expect(result).toEqual({
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
+ playlist: {
+ ...resolvedPlaylist,
+ stalkerSessionPatch: {
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity:
+ stalkerSessionFingerprint(resolvedPlaylist),
+ stalkerWatchdogTimeout: 75,
+ stalkerTimeslot: 8,
+ stalkerAccountInfo: {
+ login: 'subscriber',
+ expireDate: 1800000000,
+ tariffPlanName: 'Premium',
+ status: 0,
+ },
+ },
+ },
+ });
+ });
+
+ it('returns a user-facing portal refusal without producing an update', async () => {
+ discovery.discover.mockResolvedValue({
+ status: 'auth-rejected',
+ portalUrl: 'https://portal.example.com/server/load.php',
+ error: new StalkerPortalError(
+ 'login-rejected',
+ 'Subscription expired'
+ ),
+ });
+
+ await expect(service.resolveConnection(draft)).resolves.toEqual({
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
+ message:
+ 'HOME.STALKER_PORTAL.LOGIN_REJECTED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
+ });
+ });
+
+ it('returns a dedicated error when no endpoint can be reached', async () => {
+ discovery.discover.mockResolvedValue({ status: 'unreachable' });
+
+ await expect(service.resolveConnection(draft)).resolves.toEqual({
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
+ message: 'HOME.STALKER_PORTAL.EDIT_UNREACHABLE',
+ });
+ });
+
+ it('replaces the active runtime playlist and session after a resolved full-portal edit', async () => {
+ activePlaylist = {
+ ...draft,
+ portalUrl: 'https://old.example.com/portal.php',
+ isFullStalkerPortal: false,
+ };
+ const resolvedPlaylist = {
+ ...draft,
+ portalUrl: 'https://new.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ stalkerSessionPatch: {
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ stalkerTimeslot: 5,
+ },
+ };
+
+ await service.applyResolvedConnection(resolvedPlaylist);
+
+ expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith(
+ draft._id
+ );
+ expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ portalUrl: 'https://new.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ stalkerTimeslot: 5,
+ })
+ );
+ expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
+ expect.objectContaining({
+ portalUrl: 'https://new.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ })
+ );
+ expect(activePlaylist?.portalUrl).toBe(
+ 'https://new.example.com/server/load.php'
+ );
+ });
+
+ it('clears the session and stops full-portal runtime behavior after a resolved simple edit', async () => {
+ activePlaylist = {
+ ...draft,
+ portalUrl: 'https://old.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ };
+ const resolvedPlaylist = {
+ ...draft,
+ portalUrl: 'https://new.example.com/portal.php',
+ isFullStalkerPortal: false,
+ stalkerSessionPatch: null,
+ };
+
+ await service.applyResolvedConnection(resolvedPlaylist);
+
+ expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith(
+ draft._id
+ );
+ expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ portalUrl: 'https://new.example.com/portal.php',
+ isFullStalkerPortal: false,
+ stalkerToken: undefined,
+ stalkerSessionIdentity: undefined,
+ })
+ );
+ expect(activePlaylist).toEqual(
+ expect.objectContaining({
+ portalUrl: 'https://new.example.com/portal.php',
+ isFullStalkerPortal: false,
+ stalkerToken: undefined,
+ stalkerSessionIdentity: undefined,
+ })
+ );
+ });
+
+ it('repoints the active snapshot before an old authentication finishes draining', async () => {
+ activePlaylist = {
+ ...draft,
+ portalUrl: 'https://old.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ };
+ let finishReplacement: () => void = () => undefined;
+ stalkerSession.replaceSessionAfterEdit.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishReplacement = resolve;
+ })
+ );
+ const resolvedPlaylist = {
+ ...draft,
+ portalUrl: 'https://new.example.com/portal.php',
+ isFullStalkerPortal: false,
+ stalkerSessionPatch: null,
+ };
+
+ const applying = service.applyResolvedConnection(resolvedPlaylist);
+
+ expect(activePlaylist?.portalUrl).toBe(
+ 'https://new.example.com/portal.php'
+ );
+ let settled = false;
+ void applying.then(() => (settled = true));
+ await Promise.resolve();
+ expect(settled).toBe(false);
+
+ finishReplacement();
+ await applying;
+ });
+
+ it.each([true, false])(
+ 'keeps the exact credential-only Edit result when a prior endpoint repair exists (active=%s)',
+ async (isActive) => {
+ const resolvedPlaylist = {
+ ...draft,
+ portalUrl: 'https://portal.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ username: 'new-user',
+ stalkerSessionPatch: {
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ stalkerTimeslot: 5,
+ },
+ };
+ // This is the remembered A→B repair that used to rewrite the
+ // newly proven A endpoint when only credentials changed.
+ portalRepair.applyOverride.mockReturnValue({
+ ...resolvedPlaylist,
+ portalUrl: 'https://portal.example.com/portal.php',
+ });
+ activePlaylist = isActive
+ ? {
+ ...draft,
+ portalUrl: 'https://portal.example.com/portal.php',
+ }
+ : undefined;
+
+ await service.applyResolvedConnection(resolvedPlaylist);
+
+ expect(portalRepair.applyOverride).not.toHaveBeenCalled();
+ expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith(
+ draft._id
+ );
+ expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ portalUrl: 'https://portal.example.com/server/load.php',
+ username: 'new-user',
+ stalkerToken: 'NEW_TOKEN',
+ })
+ );
+ expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledTimes(
+ isActive ? 1 : 0
+ );
+ if (isActive) {
+ expect(activePlaylist?.portalUrl).toBe(
+ 'https://portal.example.com/server/load.php'
+ );
+ }
+ }
+ );
+});
diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts
new file mode 100644
index 000000000..473692511
--- /dev/null
+++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts
@@ -0,0 +1,199 @@
+import { inject, Injectable } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import {
+ asStalkerPortalError,
+ StalkerPortalDiscoveryService,
+ StalkerPortalRepairService,
+ StalkerSessionService,
+ StalkerStore,
+ STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
+ stalkerSessionFingerprint,
+ type StalkerPortalErrorKind,
+} from '@iptvnator/portal/stalker/data-access';
+import {
+ STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
+ type StalkerPlaylistConnectionEditor,
+ type StalkerPlaylistConnectionResult,
+} from '@iptvnator/playlist/shared/ui';
+import {
+ normalizeStalkerPortalIdentity,
+ type Playlist,
+ type PlaylistMeta,
+ type PlaylistMetaUpdate,
+} from '@iptvnator/shared/interfaces';
+
+const STALKER_EDIT_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',
+};
+
+@Injectable({ providedIn: 'root' })
+export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylistConnectionEditor {
+ private readonly discovery = inject(StalkerPortalDiscoveryService);
+ private readonly portalRepair = inject(StalkerPortalRepairService);
+ private readonly stalkerSession = inject(StalkerSessionService);
+ private readonly stalkerStore = inject(StalkerStore);
+ private readonly translate = inject(TranslateService);
+
+ async applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise {
+ // Edit discovery is newer and more authoritative than a lazy repair
+ // remembered for the previous connection. Applying that override to
+ // the resolved row could turn a credential-only A→A edit back into
+ // the repair's old A→B result.
+ const runtimePlaylist = this.toRuntimePlaylist(playlist);
+ this.portalRepair.retireForPlaylistEdit(runtimePlaylist._id);
+ const sessionReplacement =
+ this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist);
+
+ let activePlaylistUpdate = Promise.resolve();
+ if (this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id) {
+ // `setCurrentPlaylist` patches the signal-store snapshot before
+ // its optional SQLite sync awaits, and re-points/stops the active
+ // watchdog synchronously. The next catalog/playback request can
+ // therefore no longer use the pre-edit endpoint or identity.
+ activePlaylistUpdate =
+ this.stalkerStore.setCurrentPlaylist(runtimePlaylist);
+ }
+ await Promise.all([sessionReplacement, activePlaylistUpdate]);
+ }
+
+ async resolveConnection(
+ playlist: PlaylistMeta
+ ): Promise {
+ const identity = normalizeStalkerPortalIdentity({
+ serialNumber: playlist.stalkerSerialNumber,
+ deviceId1: playlist.stalkerDeviceId1,
+ deviceId2: playlist.stalkerDeviceId2,
+ signature1: playlist.stalkerSignature1,
+ signature2: playlist.stalkerSignature2,
+ });
+ const normalizedPlaylist: PlaylistMetaUpdate = {
+ ...playlist,
+ stalkerSerialNumber: identity.serialNumber ?? '',
+ stalkerDeviceId1: identity.deviceId1 ?? '',
+ stalkerDeviceId2: identity.deviceId2 ?? '',
+ stalkerSignature1: identity.signature1 ?? '',
+ stalkerSignature2: identity.signature2 ?? '',
+ };
+ const outcome = await this.discovery.discover(
+ playlist.portalUrl ?? '',
+ playlist.macAddress ?? '',
+ identity,
+ {
+ credentials: {
+ username: playlist.username ?? '',
+ password: playlist.password ?? '',
+ },
+ }
+ );
+
+ if (outcome.status === 'unreachable') {
+ return {
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
+ message: this.translate.instant(
+ 'HOME.STALKER_PORTAL.EDIT_UNREACHABLE'
+ ),
+ };
+ }
+
+ if (outcome.status === 'auth-rejected') {
+ return {
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
+ message: this.buildAuthErrorMessage(outcome.error),
+ };
+ }
+
+ const resolvedPlaylist: PlaylistMetaUpdate = {
+ ...normalizedPlaylist,
+ portalUrl: outcome.portalUrl,
+ isFullStalkerPortal: outcome.isFullStalkerPortal,
+ };
+
+ if (!outcome.isFullStalkerPortal) {
+ return {
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
+ playlist: {
+ ...resolvedPlaylist,
+ stalkerSessionPatch: null,
+ },
+ };
+ }
+
+ if (!outcome.token) {
+ return {
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
+ message: this.translate.instant(
+ 'HOME.STALKER_PORTAL.AUTH_FAILED'
+ ),
+ };
+ }
+
+ return {
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
+ playlist: {
+ ...resolvedPlaylist,
+ stalkerSessionPatch: {
+ stalkerToken: outcome.token,
+ stalkerSessionIdentity:
+ stalkerSessionFingerprint(resolvedPlaylist),
+ stalkerWatchdogTimeout:
+ outcome.watchdogTimeoutSeconds ??
+ STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
+ stalkerTimeslot: outcome.timeslotSeconds ?? 0,
+ stalkerAccountInfo: outcome.accountInfo
+ ? {
+ login: outcome.accountInfo.login,
+ expireDate: outcome.accountInfo.expire_date,
+ tariffPlanName:
+ outcome.accountInfo.tariff_plan_name,
+ status: outcome.accountInfo.status,
+ }
+ : undefined,
+ },
+ },
+ };
+ }
+
+ private buildAuthErrorMessage(error: unknown): string {
+ const portalError = asStalkerPortalError(error);
+ const headline = this.translate.instant(
+ portalError
+ ? STALKER_EDIT_ERROR_KEY_BY_KIND[portalError.kind]
+ : 'HOME.STALKER_PORTAL.AUTH_FAILED'
+ );
+
+ if (!portalError?.portalText) {
+ return headline;
+ }
+
+ return `${headline} ${this.translate.instant(
+ 'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
+ { message: portalError.portalText }
+ )}`;
+ }
+
+ private toRuntimePlaylist(update: PlaylistMetaUpdate): Playlist {
+ const { stalkerSessionPatch, ...metadata } = update;
+ return {
+ lastUsage: '',
+ ...metadata,
+ ...(stalkerSessionPatch !== undefined
+ ? {
+ stalkerToken: stalkerSessionPatch?.stalkerToken,
+ stalkerSessionIdentity:
+ stalkerSessionPatch?.stalkerSessionIdentity,
+ stalkerWatchdogTimeout:
+ stalkerSessionPatch?.stalkerWatchdogTimeout,
+ stalkerTimeslot: stalkerSessionPatch?.stalkerTimeslot,
+ stalkerAccountInfo:
+ stalkerSessionPatch?.stalkerAccountInfo,
+ }
+ : {}),
+ } as Playlist;
+ }
+}
diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 0502c49da..f89ff8043 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "التوقيع 2 (اختياري)",
"SIGNATURE_HINT": "64 حرفًا سداسيًا عشريًا - استخدمه إذا كان المزود يتطلب توقيعات للتحقق من الجهاز",
"SERVER_URL": "رابط الخادم",
- "URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)",
+ "URL_HINT": "أدخل اسم مضيف أو عنوانًا ينتهي بـ /c أو portal.php أو server/load.php.",
+ "URL_VALIDATION_ERROR": "أدخل رابطًا صالحًا يبدأ بـ http:// أو https://.",
"ADD": "إضافة",
"VALIDATING": "جارٍ التحقق من البوابة...",
"CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور",
"AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.",
+ "EDIT_UNREACHABLE": "لم تستجب البوابة. لم يتم حفظ أي تغييرات. تحقق من الرابط وحاول مرة أخرى.",
"LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.",
"LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.",
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 581fcfc72..0badb1c0e 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "التوقيع 2 (اختياري)",
"SIGNATURE_HINT": "64 حرف hex - استعملو إذا المزود كيتطلب توقيعات التحقق من الجهاز",
"SERVER_URL": "رابط الخادم",
- "URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)",
+ "URL_HINT": "دخل الهوست ولا عنوان كيسالي بـ /c ولا portal.php ولا server/load.php.",
+ "URL_VALIDATION_ERROR": "دخل رابط صحيح كيبدا بـ http:// ولا https://.",
"ADD": "زيد",
"VALIDATING": "جاري التحقق من البوابة...",
"CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور",
"AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.",
+ "EDIT_UNREACHABLE": "البوابة ما جاوباتش. ما تحفظ حتى تغيير. تأكد من الرابط وعاود المحاولة.",
"LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.",
"LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.",
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 84d57fdd6..178614b53 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Подпіс 2 (неабавязкова)",
"SIGNATURE_HINT": "64 шаснаццатковыя сімвалы — выкарыстоўвайце, калі правайдэр патрабуе подпісы для верыфікацыі прылады",
"SERVER_URL": "URL сервера",
- "URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).",
+ "URL_HINT": "Увядзіце хост або адрас, які заканчваецца на /c, portal.php ці server/load.php.",
+ "URL_VALIDATION_ERROR": "Увядзіце правільны URL, які пачынаецца з http:// або https://.",
"ADD": "Дадаць",
"VALIDATING": "Праверка партала...",
"CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль",
"AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.",
+ "EDIT_UNREACHABLE": "Партал не адказаў. Змены не захаваны. Праверце URL і паўтарыце спробу.",
"LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.",
"LOGIN_REJECTED": "Партал адхіліў лагін і пароль.",
"PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.",
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index ebf158524..0ba4fc7af 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Signatur 2 (optional)",
"SIGNATURE_HINT": "64 Hex-Zeichen – verwenden, wenn der Anbieter Geräte-Verifizierungssignaturen verlangt",
"SERVER_URL": "Server-URL",
- "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)",
+ "URL_HINT": "Geben Sie einen Host oder eine Adresse mit /c, portal.php oder server/load.php ein.",
+ "URL_VALIDATION_ERROR": "Geben Sie eine gültige URL mit http:// oder https:// ein.",
"ADD": "Hinzufügen",
"VALIDATING": "Portal wird überprüft …",
"CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt",
"AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.",
+ "EDIT_UNREACHABLE": "Das Portal hat nicht geantwortet. Es wurden keine Änderungen gespeichert. Prüfen Sie die URL und versuchen Sie es erneut.",
"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.",
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index a7b2a8b99..7c2548fb6 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Υπογραφή 2 (Προαιρετικό)",
"SIGNATURE_HINT": "64 δεκαεξαδικοί χαρακτήρες - χρησιμοποιήστε εάν ο πάροχος απαιτεί υπογραφές επαλήθευσης συσκευής",
"SERVER_URL": "Διεύθηνση URL σέρβερ",
- "URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)",
+ "URL_HINT": "Εισαγάγετε έναν host ή μια διεύθυνση που τελειώνει σε /c, portal.php ή server/load.php.",
+ "URL_VALIDATION_ERROR": "Εισαγάγετε μια έγκυρη διεύθυνση URL που αρχίζει με http:// ή https://.",
"ADD": "Προσθήκη",
"VALIDATING": "Επικύρωση πύλης...",
"CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης",
"AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.",
+ "EDIT_UNREACHABLE": "Η πύλη δεν απάντησε. Δεν αποθηκεύτηκαν αλλαγές. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.",
"LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.",
"LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.",
"PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index a5754aa74..d8f3c3c99 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Signature 2 (Optional)",
"SIGNATURE_HINT": "64 hex characters - use if provider requires device verification signatures",
"SERVER_URL": "Server URL",
- "URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)",
+ "URL_HINT": "Enter a host or an address ending in /c, portal.php, or server/load.php.",
+ "URL_VALIDATION_ERROR": "Enter a valid http:// or https:// URL.",
"ADD": "Add",
"VALIDATING": "Validating portal...",
"CREDENTIALS_HINT": "Only needed when the portal asks for a login and password",
"AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.",
+ "EDIT_UNREACHABLE": "The portal did not respond. No changes were saved. Check the URL and try again.",
"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.",
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index baad2e909..305b93ac0 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Firma 2 (opcional)",
"SIGNATURE_HINT": "64 caracteres hexadecimales — úsalo si el proveedor requiere firmas de verificación del dispositivo",
"SERVER_URL": "URL del servidor",
- "URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).",
+ "URL_HINT": "Introduce un host o una dirección que termine en /c, portal.php o server/load.php.",
+ "URL_VALIDATION_ERROR": "Introduce una URL válida que empiece por http:// o https://.",
"ADD": "Agregar",
"VALIDATING": "Validando portal…",
"CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña",
"AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.",
+ "EDIT_UNREACHABLE": "El portal no respondió. No se guardaron los cambios. Comprueba la URL e inténtalo de nuevo.",
"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.",
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index ecf59f1b3..60dbb3e7c 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Signature 2 (optionnelle)",
"SIGNATURE_HINT": "64 caractères hexadécimaux — à utiliser si le fournisseur exige des signatures de vérification d'appareil",
"SERVER_URL": "URL du serveur",
- "URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)",
+ "URL_HINT": "Saisissez un hôte ou une adresse se terminant par /c, portal.php ou server/load.php.",
+ "URL_VALIDATION_ERROR": "Saisissez une URL valide commençant par http:// ou https://.",
"ADD": "Ajouter",
"VALIDATING": "Validation du portail…",
"CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe",
"AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.",
+ "EDIT_UNREACHABLE": "Le portail n'a pas répondu. Aucune modification n'a été enregistrée. Vérifiez l'URL et réessayez.",
"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.",
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index cadfbb300..4e58d90cb 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -199,11 +199,13 @@
"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",
"SERVER_URL": "Kiszolgáló URL-címe",
- "URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)",
+ "URL_HINT": "Adjon meg egy gazdagépet vagy /c, portal.php vagy server/load.php végű címet.",
+ "URL_VALIDATION_ERROR": "Adjon meg egy http:// vagy https:// kezdetű érvényes URL-címet.",
"ADD": "Hozzáadás",
"VALIDATING": "Portál ellenőrzése…",
"CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér",
"AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.",
+ "EDIT_UNREACHABLE": "A portál nem válaszolt. A módosítások nem lettek mentve. Ellenőrizze az URL-címet, és próbálja újra.",
"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.",
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index c3b25fc7b..6cf7f5076 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Firma 2 (opzionale)",
"SIGNATURE_HINT": "64 caratteri esadecimali — usa se il fornitore richiede firme di verifica del dispositivo",
"SERVER_URL": "URL server",
- "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)",
+ "URL_HINT": "Inserisci un host o un indirizzo che termini con /c, portal.php o server/load.php.",
+ "URL_VALIDATION_ERROR": "Inserisci un URL valido che inizi con http:// o https://.",
"ADD": "Aggiungi",
"VALIDATING": "Validazione del portale...",
"CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password",
"AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.",
+ "EDIT_UNREACHABLE": "Il portale non ha risposto. Nessuna modifica è stata salvata. Controlla l'URL e riprova.",
"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.",
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index 3734536b3..f1b232f65 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "署名 2(任意)",
"SIGNATURE_HINT": "16進64文字。プロバイダーがデバイス検証署名を要求する場合に使用",
"SERVER_URL": "サーバーURL",
- "URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)",
+ "URL_HINT": "ホスト、または /c、portal.php、server/load.php で終わるアドレスを入力してください。",
+ "URL_VALIDATION_ERROR": "http:// または https:// で始まる有効なURLを入力してください。",
"ADD": "追加",
"VALIDATING": "ポータルを検証中...",
"CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要",
"AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。",
+ "EDIT_UNREACHABLE": "ポータルが応答しませんでした。変更は保存されていません。URLを確認して再試行してください。",
"LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。",
"LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。",
"PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。",
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 6653602ad..1ce444e63 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "서명 2 (선택 사항)",
"SIGNATURE_HINT": "16진수 64자 - 공급자가 기기 검증 서명을 요구할 때 사용",
"SERVER_URL": "서버 URL",
- "URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).",
+ "URL_HINT": "호스트 또는 /c, portal.php, server/load.php로 끝나는 주소를 입력하세요.",
+ "URL_VALIDATION_ERROR": "http:// 또는 https://로 시작하는 올바른 URL을 입력하세요.",
"ADD": "추가하다",
"VALIDATING": "포털을 검증하는 중...",
"CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요",
"AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.",
+ "EDIT_UNREACHABLE": "포털이 응답하지 않았습니다. 변경 사항이 저장되지 않았습니다. URL을 확인하고 다시 시도하세요.",
"LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.",
"LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.",
"PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.",
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index 4f0b05448..25394c72f 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Handtekening 2 (optioneel)",
"SIGNATURE_HINT": "64 hex-tekens — gebruiken als de provider apparaatverificatiehandtekeningen vereist",
"SERVER_URL": "Server URL",
- "URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)",
+ "URL_HINT": "Voer een host of adres in dat eindigt op /c, portal.php of server/load.php.",
+ "URL_VALIDATION_ERROR": "Voer een geldige URL in die begint met http:// of https://.",
"ADD": "Toevoegen",
"VALIDATING": "Portaal valideren...",
"CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt",
"AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.",
+ "EDIT_UNREACHABLE": "Het portaal reageerde niet. Er zijn geen wijzigingen opgeslagen. Controleer de URL en probeer het opnieuw.",
"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.",
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index a3165f818..277d699b0 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Sygnatura 2 (opcjonalnie)",
"SIGNATURE_HINT": "64 znaki szesnastkowe – użyj, jeśli dostawca wymaga sygnatur weryfikacji urządzenia",
"SERVER_URL": "URL serwera",
- "URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)",
+ "URL_HINT": "Wprowadź host lub adres zakończony /c, portal.php albo server/load.php.",
+ "URL_VALIDATION_ERROR": "Wprowadź poprawny URL zaczynający się od http:// lub https://.",
"ADD": "Dodaj",
"VALIDATING": "Walidacja portalu...",
"CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła",
"AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.",
+ "EDIT_UNREACHABLE": "Portal nie odpowiedział. Nie zapisano żadnych zmian. Sprawdź URL i spróbuj ponownie.",
"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.",
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index 34ce3e6e3..6ae7c2086 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Assinatura 2 (opcional)",
"SIGNATURE_HINT": "64 caracteres hexadecimais - use se o provedor exigir assinaturas de verificação do dispositivo",
"SERVER_URL": "URL do servidor",
- "URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)",
+ "URL_HINT": "Introduza um host ou endereço terminado em /c, portal.php ou server/load.php.",
+ "URL_VALIDATION_ERROR": "Introduza um URL válido que comece por http:// ou https://.",
"ADD": "Adicionar",
"VALIDATING": "Validando portal...",
"CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha",
"AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.",
+ "EDIT_UNREACHABLE": "O portal não respondeu. Nenhuma alteração foi guardada. Verifique o URL e tente novamente.",
"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.",
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index f95a46827..a21a0a70e 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "Подпись 2 (необязательно)",
"SIGNATURE_HINT": "64 шестнадцатеричных символа — используйте, если провайдер требует подписи проверки устройства",
"SERVER_URL": "URL сервера",
- "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).",
+ "URL_HINT": "Введите хост или адрес, оканчивающийся на /c, portal.php или server/load.php.",
+ "URL_VALIDATION_ERROR": "Введите корректный URL, начинающийся с http:// или https://.",
"ADD": "Добавить",
"VALIDATING": "Проверка портала…",
"CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль",
"AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.",
+ "EDIT_UNREACHABLE": "Портал не ответил. Изменения не сохранены. Проверьте URL и повторите попытку.",
"LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.",
"LOGIN_REJECTED": "Портал отклонил логин и пароль.",
"PORTAL_REFUSED": "Портал отказал в доступе этому устройству.",
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index 14df41536..1fd641464 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -199,11 +199,13 @@
"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",
"SERVER_URL": "Sunucu URL'si",
- "URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)",
+ "URL_HINT": "/c, portal.php veya server/load.php ile biten bir ana makine ya da adres girin.",
+ "URL_VALIDATION_ERROR": "http:// veya https:// ile başlayan geçerli bir URL girin.",
"ADD": "Ekle",
"VALIDATING": "Portal doğrulanıyor...",
"CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir",
"AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.",
+ "EDIT_UNREACHABLE": "Portal yanıt vermedi. Hiçbir değişiklik kaydedilmedi. URL'yi kontrol edip tekrar deneyin.",
"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.",
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index 983395365..6d0b5e1cb 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "签名 2(可选)",
"SIGNATURE_HINT": "64 位十六进制字符 — 如提供商需要设备验证签名时使用",
"SERVER_URL": "服务器 URL",
- "URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
+ "URL_HINT": "请输入主机或以 /c、portal.php、server/load.php 结尾的地址。",
+ "URL_VALIDATION_ERROR": "请输入以 http:// 或 https:// 开头的有效 URL。",
"ADD": "添加",
"VALIDATING": "正在验证门户…",
"CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写",
"AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。",
+ "EDIT_UNREACHABLE": "门户没有响应。未保存任何更改。请检查 URL 后重试。",
"LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。",
"LOGIN_REJECTED": "门户拒绝了该登录名和密码。",
"PORTAL_REFUSED": "门户拒绝了此设备的访问。",
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index 09cfd812f..211b53c88 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -199,11 +199,13 @@
"SIGNATURE_2": "簽章 2(選填)",
"SIGNATURE_HINT": "64 個十六進位字元——若供應商要求裝置驗證簽章時請填寫",
"SERVER_URL": "伺服器網址",
- "URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
+ "URL_HINT": "請輸入主機或以 /c、portal.php、server/load.php 結尾的位址。",
+ "URL_VALIDATION_ERROR": "請輸入以 http:// 或 https:// 開頭的有效網址。",
"ADD": "新增",
"VALIDATING": "正在驗證入口網站...",
"CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫",
"AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。",
+ "EDIT_UNREACHABLE": "入口網站沒有回應。未儲存任何變更。請檢查網址後再試一次。",
"LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。",
"LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。",
"PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。",
diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts
index 3f6008509..45703e9c5 100644
--- a/libs/m3u-state/src/lib/actions.ts
+++ b/libs/m3u-state/src/lib/actions.ts
@@ -4,6 +4,7 @@ import {
EpgProgram,
Playlist,
PlaylistMeta,
+ PlaylistMetaUpdate,
} from '@iptvnator/shared/interfaces';
export const PlaylistActions = createActionGroup({
@@ -14,7 +15,7 @@ export const PlaylistActions = createActionGroup({
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
- 'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
+ 'Update Playlist Meta': props<{ playlist: PlaylistMetaUpdate }>(),
'Update Playlist': props<{
/**
* Instrumentation-only; stripped before the DB invoke.
diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
index 89b9e61a0..394182ed6 100644
--- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
+++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
@@ -46,6 +46,125 @@ describe('playlistReducers', () => {
).toEqual(['Movies', 'News']);
});
+ it('updates a resolved Stalker connection and projects the transient session patch', () => {
+ const existingPlaylist = {
+ _id: 'stalker-1',
+ title: 'Stalker Portal',
+ count: 0,
+ importDate: '2026-08-08T00:00:00.000Z',
+ portalUrl: 'https://old.example.com/portal.php',
+ macAddress: '00:1A:79:AA:BB:CC',
+ isFullStalkerPortal: false,
+ stalkerSerialNumber: 'OLD-SERIAL',
+ } as PlaylistMeta;
+ const state = {
+ ...initialState,
+ playlists: playlistsAdapter.addOne(
+ existingPlaylist,
+ initialState.playlists
+ ),
+ };
+
+ const nextState = reducer(
+ state,
+ PlaylistActions.updatePlaylistMeta({
+ playlist: {
+ ...existingPlaylist,
+ portalUrl: 'https://new.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ macAddress: '00:1A:79:DD:EE:FF',
+ username: 'subscriber',
+ password: 'secret',
+ stalkerSerialNumber: 'NEW-SERIAL',
+ stalkerDeviceId1: 'DEVICE-1',
+ stalkerDeviceId2: 'DEVICE-2',
+ stalkerSignature1: 'SIGNATURE-1',
+ stalkerSignature2: 'SIGNATURE-2',
+ stalkerSessionPatch: {
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ stalkerTimeslot: 3,
+ stalkerAccountInfo: {
+ login: 'subscriber',
+ status: 'active',
+ },
+ },
+ },
+ })
+ );
+ const stored = nextState.playlists.entities['stalker-1'];
+
+ expect(stored).toEqual(
+ expect.objectContaining({
+ portalUrl: 'https://new.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ macAddress: '00:1A:79:DD:EE:FF',
+ username: 'subscriber',
+ password: 'secret',
+ stalkerSerialNumber: 'NEW-SERIAL',
+ stalkerDeviceId1: 'DEVICE-1',
+ stalkerDeviceId2: 'DEVICE-2',
+ stalkerSignature1: 'SIGNATURE-1',
+ stalkerSignature2: 'SIGNATURE-2',
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ stalkerTimeslot: 3,
+ stalkerAccountInfo: {
+ login: 'subscriber',
+ status: 'active',
+ },
+ })
+ );
+ expect(stored).not.toHaveProperty('stalkerSessionPatch');
+ });
+
+ it('clears the active Stalker session when the transient patch is null', () => {
+ const existingPlaylist = {
+ _id: 'stalker-1',
+ title: 'Stalker Portal',
+ count: 0,
+ importDate: '2026-08-08T00:00:00.000Z',
+ portalUrl: 'https://old.example.com/server/load.php',
+ macAddress: '00:1A:79:AA:BB:CC',
+ stalkerToken: 'OLD_TOKEN',
+ stalkerSessionIdentity: 'old-fingerprint',
+ stalkerWatchdogTimeout: 120,
+ stalkerTimeslot: 7,
+ stalkerAccountInfo: { login: 'old-user' },
+ } as Playlist;
+ const state = {
+ ...initialState,
+ playlists: playlistsAdapter.addOne(
+ existingPlaylist,
+ initialState.playlists
+ ),
+ };
+
+ const nextState = reducer(
+ state,
+ PlaylistActions.updatePlaylistMeta({
+ playlist: {
+ ...existingPlaylist,
+ stalkerSessionPatch: null,
+ },
+ })
+ );
+ const stored = nextState.playlists.entities['stalker-1'];
+
+ expect(stored).toEqual(
+ expect.objectContaining({
+ stalkerToken: undefined,
+ stalkerSessionIdentity: undefined,
+ stalkerWatchdogTimeout: undefined,
+ stalkerTimeslot: undefined,
+ stalkerAccountInfo: undefined,
+ })
+ );
+ expect(stored).not.toHaveProperty('stalkerSessionPatch');
+ });
+
it('updates the active playlist channel cache and clears loading on playlist refresh', () => {
const refreshedChannel = {
epgParams: '',
@@ -160,9 +279,9 @@ describe('playlistReducers', () => {
})
);
- expect(
- nextState.playlists.entities['playlist-1']?.autoRefresh
- ).toBe(true);
+ expect(nextState.playlists.entities['playlist-1']?.autoRefresh).toBe(
+ true
+ );
});
it('keeps autoRefresh disabled on playlist refresh when the existing playlist has it disabled', () => {
@@ -194,8 +313,8 @@ describe('playlistReducers', () => {
})
);
- expect(
- nextState.playlists.entities['playlist-1']?.autoRefresh
- ).toBe(false);
+ expect(nextState.playlists.entities['playlist-1']?.autoRefresh).toBe(
+ false
+ );
});
});
diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
index fb22c6be4..1d92e88bb 100644
--- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
+++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
@@ -39,11 +39,9 @@ export const playlistReducers = [
detectedEpgUrls:
action.playlist.detectedEpgUrls ??
currentPlaylist?.detectedEpgUrls,
- enabledEpgUrls:
- action.playlist.epgUrls ?? currentPlaylist?.epgUrls,
+ enabledEpgUrls: action.playlist.epgUrls ?? currentPlaylist?.epgUrls,
manualEpgUrls:
- action.playlist.manualEpgUrls ??
- currentPlaylist?.manualEpgUrls,
+ action.playlist.manualEpgUrls ?? currentPlaylist?.manualEpgUrls,
disabledEpgUrls:
action.playlist.disabledEpgUrls ??
currentPlaylist?.disabledEpgUrls,
@@ -151,6 +149,39 @@ export const playlistReducers = [
isFullStalkerPortal: p.isFullStalkerPortal,
}
: {}),
+ ...(p.stalkerSerialNumber !== undefined
+ ? {
+ stalkerSerialNumber: p.stalkerSerialNumber,
+ }
+ : {}),
+ ...(p.stalkerDeviceId1 !== undefined
+ ? { stalkerDeviceId1: p.stalkerDeviceId1 }
+ : {}),
+ ...(p.stalkerDeviceId2 !== undefined
+ ? { stalkerDeviceId2: p.stalkerDeviceId2 }
+ : {}),
+ ...(p.stalkerSignature1 !== undefined
+ ? { stalkerSignature1: p.stalkerSignature1 }
+ : {}),
+ ...(p.stalkerSignature2 !== undefined
+ ? { stalkerSignature2: p.stalkerSignature2 }
+ : {}),
+ ...(p.stalkerSessionPatch !== undefined
+ ? {
+ stalkerToken:
+ p.stalkerSessionPatch?.stalkerToken,
+ stalkerSessionIdentity:
+ p.stalkerSessionPatch
+ ?.stalkerSessionIdentity,
+ stalkerWatchdogTimeout:
+ p.stalkerSessionPatch
+ ?.stalkerWatchdogTimeout,
+ stalkerTimeslot:
+ p.stalkerSessionPatch?.stalkerTimeslot,
+ stalkerAccountInfo:
+ p.stalkerSessionPatch?.stalkerAccountInfo,
+ }
+ : {}),
...(p.favorites != null
? { favorites: p.favorites }
: {}),
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 c2491114f..4dc53d6a7 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
@@ -15,10 +15,10 @@
id="portalUrl"
formControlName="portalUrl"
/>
- {{
+ {{ 'HOME.STALKER_PORTAL.URL_HINT' | translate }}
+ {{
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
- }}
- {{ 'SETTINGS.EPG_URL_ERROR' | 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 899c7e992..9c9e0ef9b 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
@@ -72,6 +72,19 @@ describe('StalkerPortalImportComponent identity handling', () => {
);
});
+ it('accepts HTTP(S) bare hosts and rejects URLs without a web protocol', () => {
+ const control = component.form.controls.portalUrl;
+
+ control.setValue('portal.example.com');
+ expect(control.valid).toBe(false);
+
+ control.setValue('file:///tmp/portal.php');
+ expect(control.valid).toBe(false);
+
+ control.setValue('https://portal.example.com');
+ expect(control.valid).toBe(true);
+ });
+
it('passes trimmed SN, device IDs, and signatures into initial authentication and persisted playlist metadata', async () => {
component.form.patchValue({
_id: 'playlist-1',
@@ -189,7 +202,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
it('persists the cadence and the identity the token was negotiated for', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
- portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
+ portalUrl:
+ 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
watchdogTimeoutSeconds: 90,
@@ -220,7 +234,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
// exactly the login portals this flow exists for.
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
- portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
+ portalUrl:
+ 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
});
@@ -251,7 +266,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
it('records the effective cadence when the portal advertises none', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
- portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
+ portalUrl:
+ 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
});
@@ -275,7 +291,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
it("relays the portal's own refusal instead of a generic error", async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
- portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
+ portalUrl:
+ 'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError('login-required'),
});
component.form.patchValue({
@@ -299,7 +316,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
it("appends the portal's explanation to a blocked refusal", async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
- portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
+ portalUrl:
+ 'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError(
'blocked',
'device conflict - device_id mismatch'
@@ -517,7 +535,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
);
});
- it('does not submit a MAC paired with the previous MAC\'s IDs', async () => {
+ 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
@@ -568,9 +586,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
const delayed = call++ < 2;
const result = await realDigest(algorithm, data);
if (delayed) {
- await new Promise((resolve) =>
- setTimeout(resolve, 20)
- );
+ await new Promise((resolve) => setTimeout(resolve, 20));
}
return result;
}) as typeof globalThis.crypto.subtle.digest);
@@ -727,7 +743,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
);
});
- it('never pairs one MAC with another MAC\'s device IDs', async () => {
+ 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
@@ -860,7 +876,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
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',
+ portalUrl:
+ 'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError(
'device-conflict',
'device conflict - device_id mismatch'
@@ -907,4 +924,24 @@ describe('StalkerPortalImportComponent identity handling', () => {
})
);
});
+
+ it('persists portal.php instead of the root page for an unreachable bare host', async () => {
+ portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
+ component.form.patchValue({
+ _id: 'playlist-bare-host',
+ title: 'Offline Panel',
+ macAddress: '00:1A:79:AA:BB:CC',
+ portalUrl: 'https://panel.example.com',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+
+ await component.addPlaylist();
+
+ expect(store.dispatch.mock.calls[0][0].playlist).toEqual(
+ expect.objectContaining({
+ portalUrl: 'https://panel.example.com/portal.php',
+ isFullStalkerPortal: false,
+ })
+ );
+ });
});
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 f3634a28c..566983054 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
@@ -92,7 +92,7 @@ interface StalkerSettledIdentity {
})
export class StalkerPortalImportComponent {
readonly addClicked = output();
- readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
+ readonly URL_REGEX = /^https?:\/\/[^ "\s]+$/i;
readonly form = new FormGroup({
_id: new FormControl(createRandomId()),
@@ -136,8 +136,10 @@ export class StalkerPortalImportComponent {
*/
get showsForeignOuiHint(): boolean {
const value = this.form.controls.macAddress.value;
- return Boolean(normalizeStalkerMacAddress(value)) &&
- !hasInfomirMacOui(value);
+ return (
+ Boolean(normalizeStalkerMacAddress(value)) &&
+ !hasInfomirMacOui(value)
+ );
}
/**
@@ -161,7 +163,7 @@ export class StalkerPortalImportComponent {
!this.derivesDeviceIds() &&
Boolean(
this.form.controls.deviceId1.value ||
- this.form.controls.deviceId2.value
+ this.form.controls.deviceId2.value
)
);
}
@@ -403,8 +405,7 @@ export class StalkerPortalImportComponent {
stalkerAccountInfo = {
login: discovery.accountInfo.login,
expireDate: discovery.accountInfo.expire_date,
- tariffPlanName:
- discovery.accountInfo.tariff_plan_name,
+ tariffPlanName: discovery.accountInfo.tariff_plan_name,
status: discovery.accountInfo.status,
};
}
diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts
index 112ec10dc..f7373af82 100644
--- a/libs/playlist/shared/ui/src/index.ts
+++ b/libs/playlist/shared/ui/src/index.ts
@@ -1,6 +1,7 @@
export * from './lib/add-playlist-menu/playlist-type';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
+export * from './lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token';
export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/recent-playlists/empty-state/empty-state.component';
export * from './lib/playlist-refresh-action.service';
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 60b840f47..854636854 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
@@ -5,249 +5,214 @@
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
-
-
- {{
- 'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
- }}
-
-
- @if (playlist.portalUrl) {
+
}
-
- }
- @if (canManagePlaylistEpgSources) {
-
-
- @for (epgUrl of playlistEpgUrls; track epgUrl) {
-
-
- {{
- 'SETTINGS.EPG_URL_LABEL' | translate
- }}
-
-
-
-
-
-
- }
-
+
{{
+ 'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
+ }}
+
+
+
+ {{
+ 'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
+ }}
+
+
+
+ {{
+ 'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
+ }}
+
+
+
+ {{
+ 'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
+ }}
+
+
+
+ {{
+ 'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
+ }}
+
+
+ }
+ @if (playlist.serverUrl) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
+ }}
+
+
+ }
+ @if (playlist.portalUrl || playlist.username) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
+ }}
+
+
+ }
+ @if (playlist.portalUrl || playlist.password) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
+ }}
+
+
+ }
+ @if (playlist.url) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
+ }}
+
+
+
+ }
+ @if (playlist.filename) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME'
+ | translate
+ }}
+
+
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
+ }}
+
+
+ }
+ @if (playlist.count) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
+ }}
+
+
+ }
+ @if (isDesktop) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
+ }}
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT'
+ | translate
+ }}
+
+ }
+ @if (playlist.filePath) {
+
+
+ {{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
+
+
+ @if (playlist.updateState === 2) {
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
+ | translate
+ }}
+
+ }
+
+ }
+ @if (canManagePlaylistEpgSources) {
+
- }
- @if (isDesktop && (playlist.url || playlist.filePath)) {
-
- {{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
-
-
- {{
- 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
- | translate
- }}
-
- }
-
-
- @if (!playlist.serverUrl && !playlist.portalUrl) {
+
+ }
+ @if (isDesktop && (playlist.url || playlist.filePath)) {
+
+ {{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
+
+
+ {{
+ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
+ | translate
+ }}
+
+ }
+
+
+ @if (!playlist.serverUrl && !playlist.portalUrl) {
+
+ }
+
+
- }
-
-
-
-
+
+
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 6e8fec1b2..e12cb52b4 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
@@ -14,6 +14,10 @@ import {
} from '@iptvnator/services';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistInfoComponent } from './playlist-info.component';
+import {
+ STALKER_PLAYLIST_CONNECTION_EDITOR,
+ STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
+} from './stalker-playlist-connection-editor.token';
describe('PlaylistInfoComponent', () => {
let component: PlaylistInfoComponent;
@@ -48,6 +52,9 @@ describe('PlaylistInfoComponent', () => {
let dialogRef: {
close: jest.Mock;
};
+ let stalkerConnectionEditor: {
+ resolveConnection: jest.Mock;
+ };
const originalElectron = window.electron;
const playlist = {
@@ -98,6 +105,15 @@ describe('PlaylistInfoComponent', () => {
dialogRef = {
close: jest.fn(),
};
+ stalkerConnectionEditor = {
+ applyResolvedConnection: jest.fn().mockResolvedValue(undefined),
+ resolveConnection: jest.fn(
+ async (updatedPlaylist: PlaylistMeta) => ({
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
+ playlist: updatedPlaylist,
+ })
+ ),
+ };
await TestBed.configureTestingModule({
imports: [PlaylistInfoComponent],
@@ -155,6 +171,10 @@ describe('PlaylistInfoComponent', () => {
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
+ {
+ provide: STALKER_PLAYLIST_CONNECTION_EDITOR,
+ useValue: stalkerConnectionEditor,
+ },
],
}).compileComponents();
});
@@ -621,9 +641,9 @@ describe('PlaylistInfoComponent', () => {
// 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'
- );
+ component.playlistDetails
+ .get('macAddress')
+ ?.setValue('00-1a-79-ab-cd-ef');
await component.saveChanges(
component.playlistDetails.value as PlaylistMeta
@@ -636,6 +656,178 @@ describe('PlaylistInfoComponent', () => {
}) as PlaylistMeta,
})
);
+ expect(
+ stalkerConnectionEditor.resolveConnection
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('saves metadata without discovery when connection fields are unchanged', async () => {
+ createStalkerComponent({
+ username: 'subscriber',
+ password: 'secret',
+ stalkerSerialNumber: 'SERIAL',
+ });
+ component.playlistDetails.get('title')?.setValue('Renamed');
+
+ await component.saveChanges(
+ component.playlistDetails.getRawValue() as PlaylistMeta
+ );
+
+ expect(
+ stalkerConnectionEditor.resolveConnection
+ ).not.toHaveBeenCalled();
+ expect(
+ stalkerConnectionEditor.applyResolvedConnection
+ ).not.toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ PlaylistActions.updatePlaylistMeta({
+ playlist: expect.objectContaining({
+ title: 'Renamed',
+ portalUrl: 'https://portal.example.com/c',
+ macAddress: '00:1a:79:aa:bb:cc',
+ username: 'subscriber',
+ password: 'secret',
+ stalkerSerialNumber: 'SERIAL',
+ }) as PlaylistMeta,
+ })
+ );
+ });
+
+ it.each([
+ ['portalUrl', 'https://new.example.com'],
+ ['macAddress', '00:1A:79:AB:CD:EF'],
+ ['username', 'new-user'],
+ ['password', 'new-password'],
+ ['stalkerSerialNumber', 'NEW-SERIAL'],
+ ['stalkerDeviceId1', 'NEW-DEVICE-1'],
+ ['stalkerDeviceId2', 'NEW-DEVICE-2'],
+ ['stalkerSignature1', 'NEW-SIGNATURE-1'],
+ ['stalkerSignature2', 'NEW-SIGNATURE-2'],
+ ])('runs discovery when %s changes', async (field, value) => {
+ createStalkerComponent({
+ username: 'subscriber',
+ password: 'secret',
+ stalkerSerialNumber: 'SERIAL',
+ stalkerDeviceId1: 'DEVICE-1',
+ stalkerDeviceId2: 'DEVICE-2',
+ stalkerSignature1: 'SIGNATURE-1',
+ stalkerSignature2: 'SIGNATURE-2',
+ });
+ component.playlistDetails.get(field)?.setValue(value);
+
+ await component.saveChanges(
+ component.playlistDetails.getRawValue() as PlaylistMeta
+ );
+
+ expect(
+ stalkerConnectionEditor.resolveConnection
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('dispatches the resolved endpoint, mode and session patch together', async () => {
+ createStalkerComponent();
+ const resolvedPlaylist = {
+ ...component.playlistDetails.getRawValue(),
+ portalUrl: 'https://portal.example.com/server/load.php',
+ isFullStalkerPortal: true,
+ stalkerSessionPatch: {
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ stalkerTimeslot: 4,
+ },
+ };
+ stalkerConnectionEditor.resolveConnection.mockResolvedValue({
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
+ playlist: resolvedPlaylist,
+ });
+ component.playlistDetails
+ .get('portalUrl')
+ ?.setValue('https://portal.example.com/c');
+ component.playlistDetails.get('username')?.setValue('subscriber');
+
+ await component.saveChanges(
+ component.playlistDetails.getRawValue() as PlaylistMeta
+ );
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ PlaylistActions.updatePlaylistMeta({
+ playlist: resolvedPlaylist,
+ })
+ );
+ expect(
+ stalkerConnectionEditor.applyResolvedConnection
+ ).toHaveBeenCalledWith(resolvedPlaylist);
+ expect(dialogRef.close).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
+ STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
+ ])('keeps the dialog open and saves nothing on %s', async (status) => {
+ createStalkerComponent();
+ stalkerConnectionEditor.resolveConnection.mockResolvedValue({
+ status,
+ message: `error:${status}`,
+ });
+ component.playlistDetails
+ .get('portalUrl')
+ ?.setValue('https://new.example.com');
+
+ await component.saveChanges(
+ component.playlistDetails.getRawValue() as PlaylistMeta
+ );
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ expect(dialogRef.close).not.toHaveBeenCalled();
+ expect(snackBar.open).toHaveBeenCalledWith(
+ `error:${status}`,
+ 'CLOSE',
+ { duration: 8000 }
+ );
+ expect(component.isSaving()).toBe(false);
+ });
+
+ it('ignores a second Save while discovery is pending', async () => {
+ createStalkerComponent();
+ let resolveDiscovery:
+ | ((value: { status: 'unreachable'; message: string }) => void)
+ | undefined;
+ stalkerConnectionEditor.resolveConnection.mockReturnValue(
+ new Promise((resolve) => {
+ resolveDiscovery = resolve;
+ })
+ );
+ component.playlistDetails
+ .get('portalUrl')
+ ?.setValue('https://new.example.com');
+ const value =
+ component.playlistDetails.getRawValue() as PlaylistMeta;
+
+ const firstSave = component.saveChanges(value);
+ const secondSave = component.saveChanges(value);
+
+ expect(component.isSaving()).toBe(true);
+ expect(
+ stalkerConnectionEditor.resolveConnection
+ ).toHaveBeenCalledTimes(1);
+ resolveDiscovery?.({
+ status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
+ message: 'offline',
+ });
+ await Promise.all([firstSave, secondSave]);
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('accepts a bare HTTP host but rejects an address without a protocol', () => {
+ createStalkerComponent();
+ const control = component.playlistDetails.get('portalUrl');
+
+ control?.setValue('portal.example.com');
+ expect(control?.valid).toBe(false);
+
+ control?.setValue('https://portal.example.com');
+ expect(control?.valid).toBe(true);
});
it('persists a grandfathered MAC untouched on submit', async () => {
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 e4431b2ed..3c9fcae2b 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
@@ -1,6 +1,6 @@
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { DatePipe } from '@angular/common';
-import { Component, inject } from '@angular/core';
+import { Component, inject, signal } from '@angular/core';
import {
FormControl,
ReactiveFormsModule,
@@ -38,11 +38,21 @@ import {
normalizeXtreamServerUrl,
Playlist,
PlaylistMeta,
+ PlaylistMetaUpdate,
} from '@iptvnator/shared/interfaces';
import {
normalizeEpgUrls,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
+import {
+ hasStalkerConnectionChanged,
+ preserveStalkerConnection,
+ STALKER_PORTAL_URL_PATTERN,
+} from './stalker-playlist-edit.utils';
+import {
+ STALKER_PLAYLIST_CONNECTION_EDITOR,
+ STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
+} from './stalker-playlist-connection-editor.token';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
@@ -60,6 +70,13 @@ const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/;
flex: 1 1 auto;
}
+ .playlist-info-fields {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ }
+
mat-dialog-content {
display: flex;
flex-direction: column;
@@ -167,10 +184,14 @@ export class PlaylistInfoComponent {
private runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
+ private readonly stalkerConnectionEditor = inject(
+ STALKER_PLAYLIST_CONNECTION_EDITOR
+ );
private dialogRef = inject(MatDialogRef
, {
optional: true,
});
public playlistData = inject(MAT_DIALOG_DATA);
+ readonly isSaving = signal(false);
get isDesktop(): boolean {
return this.runtime.supportsDesktopFileSave;
@@ -224,7 +245,7 @@ export class PlaylistInfoComponent {
return Boolean(
normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId1) ??
- normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId2)
+ normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId2)
);
}
@@ -316,7 +337,15 @@ export class PlaylistInfoComponent {
// title/URL/EPG edits too.
createStalkerMacAddressValidator(this.playlist.macAddress)
),
- portalUrl: new FormControl(this.playlist.portalUrl),
+ portalUrl: new FormControl(
+ this.playlist.portalUrl,
+ this.playlist.portalUrl
+ ? [
+ Validators.required,
+ Validators.pattern(STALKER_PORTAL_URL_PATTERN),
+ ]
+ : []
+ ),
stalkerSerialNumber: new FormControl(
this.playlist.stalkerSerialNumber
),
@@ -331,10 +360,48 @@ export class PlaylistInfoComponent {
}
async saveChanges(playlist: PlaylistMeta): Promise {
+ if (this.isSaving() || this.playlistDetails.invalid) {
+ return;
+ }
+
+ this.isSaving.set(true);
try {
- const normalizedPlaylist = this.normalizeStalkerPlaylistMeta(
- this.normalizeXtreamPlaylistMeta(playlist)
- );
+ let resolvedStalkerConnection = false;
+ let normalizedPlaylist: PlaylistMetaUpdate =
+ this.normalizeStalkerPlaylistMeta(
+ this.normalizeXtreamPlaylistMeta(playlist)
+ );
+ if (this.playlist.portalUrl) {
+ if (
+ hasStalkerConnectionChanged(
+ this.playlist,
+ normalizedPlaylist
+ )
+ ) {
+ const result =
+ await this.stalkerConnectionEditor.resolveConnection(
+ normalizedPlaylist
+ );
+ if (
+ result.status !==
+ STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED
+ ) {
+ this.snackBar.open(
+ result.message,
+ this.translate.instant('CLOSE'),
+ { duration: 8000 }
+ );
+ return;
+ }
+ normalizedPlaylist = result.playlist;
+ resolvedStalkerConnection = true;
+ } else {
+ normalizedPlaylist = preserveStalkerConnection(
+ this.playlist,
+ normalizedPlaylist
+ );
+ }
+ }
const isXtream =
this.playlist &&
this.playlist.username &&
@@ -351,6 +418,11 @@ export class PlaylistInfoComponent {
playlist: normalizedPlaylist,
})
);
+ if (resolvedStalkerConnection) {
+ await this.stalkerConnectionEditor.applyResolvedConnection(
+ normalizedPlaylist
+ );
+ }
this.snackBar.open(
this.translate.instant(
@@ -369,6 +441,8 @@ export class PlaylistInfoComponent {
duration: 3000,
}
);
+ } finally {
+ this.isSaving.set(false);
}
}
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts
new file mode 100644
index 000000000..187b70ad0
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts
@@ -0,0 +1,48 @@
+import { InjectionToken } from '@angular/core';
+import type {
+ PlaylistMeta,
+ PlaylistMetaUpdate,
+} from '@iptvnator/shared/interfaces';
+
+export const STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS = {
+ RESOLVED: 'resolved',
+ AUTH_REJECTED: 'auth-rejected',
+ UNREACHABLE: 'unreachable',
+} as const;
+
+export interface StalkerPlaylistConnectionResolved {
+ status: typeof STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED;
+ playlist: PlaylistMetaUpdate;
+}
+
+export interface StalkerPlaylistConnectionAuthRejected {
+ status: typeof STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED;
+ message: string;
+}
+
+export interface StalkerPlaylistConnectionUnreachable {
+ status: typeof STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE;
+ message: string;
+}
+
+interface StalkerPlaylistConnectionResultMap {
+ resolved: StalkerPlaylistConnectionResolved;
+ 'auth-rejected': StalkerPlaylistConnectionAuthRejected;
+ unreachable: StalkerPlaylistConnectionUnreachable;
+}
+
+export type StalkerPlaylistConnectionResult =
+ StalkerPlaylistConnectionResultMap[keyof StalkerPlaylistConnectionResultMap];
+
+export interface StalkerPlaylistConnectionEditor {
+ resolveConnection(
+ playlist: PlaylistMeta
+ ): Promise;
+ /** Synchronizes in-run Stalker state after the resolved update dispatches. */
+ applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise;
+}
+
+export const STALKER_PLAYLIST_CONNECTION_EDITOR =
+ new InjectionToken(
+ 'STALKER_PLAYLIST_CONNECTION_EDITOR'
+ );
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts
new file mode 100644
index 000000000..16ab8a720
--- /dev/null
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-edit.utils.ts
@@ -0,0 +1,68 @@
+import {
+ normalizeStalkerIdentityValue,
+ normalizeStalkerSerialNumber,
+ type Playlist,
+ type PlaylistMeta,
+} from '@iptvnator/shared/interfaces';
+
+export const STALKER_PORTAL_URL_PATTERN = /^\s*https?:\/\/[^ "\s]+\s*$/;
+
+const STALKER_CONNECTION_FIELDS = [
+ 'portalUrl',
+ 'macAddress',
+ 'username',
+ 'password',
+ 'stalkerSerialNumber',
+ 'stalkerDeviceId1',
+ 'stalkerDeviceId2',
+ 'stalkerSignature1',
+ 'stalkerSignature2',
+] as const;
+
+type StalkerConnectionField = (typeof STALKER_CONNECTION_FIELDS)[number];
+
+export function hasStalkerConnectionChanged(
+ current: Playlist,
+ update: PlaylistMeta
+): boolean {
+ return STALKER_CONNECTION_FIELDS.some(
+ (field) =>
+ comparableConnectionValue(field, current[field]) !==
+ comparableConnectionValue(field, update[field])
+ );
+}
+
+/** Keeps an unchanged connection byte-identical during metadata-only edits. */
+export function preserveStalkerConnection(
+ current: Playlist,
+ update: PlaylistMeta
+): PlaylistMeta {
+ return {
+ ...update,
+ portalUrl: current.portalUrl,
+ macAddress: current.macAddress,
+ username: current.username,
+ password: current.password,
+ stalkerSerialNumber: current.stalkerSerialNumber,
+ stalkerDeviceId1: current.stalkerDeviceId1,
+ stalkerDeviceId2: current.stalkerDeviceId2,
+ stalkerSignature1: current.stalkerSignature1,
+ stalkerSignature2: current.stalkerSignature2,
+ };
+}
+
+function comparableConnectionValue(
+ field: StalkerConnectionField,
+ value: string | undefined
+): string {
+ if (field === 'portalUrl') {
+ return value?.trim() ?? '';
+ }
+ if (field === 'stalkerSerialNumber') {
+ return normalizeStalkerSerialNumber(value) ?? '';
+ }
+ if (field.startsWith('stalker')) {
+ return normalizeStalkerIdentityValue(value) ?? '';
+ }
+ return value ?? '';
+}
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts b/libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts
new file mode 100644
index 000000000..aa5615c0b
--- /dev/null
+++ b/libs/portal/stalker/data-access/src/lib/stalker-authenticated-request-client.ts
@@ -0,0 +1,87 @@
+import {
+ extractStalkerAuthFailureBody,
+ isFullStalkerPortalPlaylist,
+ type Playlist,
+ STALKER_REQUEST,
+} from '@iptvnator/shared/interfaces';
+import type { DataService } from '@iptvnator/services';
+import { StalkerPortalError } from './stalker-portal-error';
+import { isStalkerAuthorizationFailure } from './stalker-response-classification';
+import type { StalkerTokenCache } from './stalker-token-cache';
+
+/** Authenticated request + one-shot session renewal shared by all callers. */
+export class StalkerAuthenticatedRequestClient {
+ constructor(
+ private readonly dataService: DataService,
+ private readonly tokens: StalkerTokenCache,
+ private readonly ensureToken: (
+ playlist: Playlist
+ ) => Promise<{ token: string | null; serialNumber?: string }>
+ ) {}
+
+ async request(
+ playlist: Playlist,
+ params: Record,
+ retryOnAuthFailure = true
+ ): Promise {
+ const { token, serialNumber } = await this.ensureToken(playlist);
+
+ try {
+ const response = await this.dataService.sendIpcEvent(
+ STALKER_REQUEST,
+ {
+ url: playlist.portalUrl,
+ macAddress: playlist.macAddress,
+ params,
+ token,
+ ...(serialNumber ? { serialNumber } : {}),
+ }
+ );
+ if (isStalkerAuthorizationFailure(response)) {
+ return this.handleAuthorizationFailure(
+ playlist,
+ params,
+ token,
+ response,
+ retryOnAuthFailure
+ );
+ }
+ return response;
+ } catch (error) {
+ // A classified response above is already wrapped with its
+ // redacted portal body. Re-classifying that app-owned error would
+ // discard the body and replace it with an empty failure.
+ if (error instanceof StalkerPortalError) {
+ throw error;
+ }
+ if (isStalkerAuthorizationFailure(error)) {
+ this.tokens.retireFailed(playlist._id, token);
+ if (
+ retryOnAuthFailure &&
+ isFullStalkerPortalPlaylist(playlist)
+ ) {
+ return this.request(playlist, params, false);
+ }
+ }
+ // Transport evidence (especially HTTP 401/403) is consumed by
+ // lazy repair. Preserve it exactly after retry exhaustion.
+ throw error;
+ }
+ }
+
+ private handleAuthorizationFailure(
+ playlist: Playlist,
+ params: Record,
+ failedToken: string | null,
+ failure: unknown,
+ retryOnAuthFailure: boolean
+ ): Promise {
+ this.tokens.retireFailed(playlist._id, failedToken);
+ if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) {
+ return this.request(playlist, params, false);
+ }
+
+ const failureBody = extractStalkerAuthFailureBody(failure) ?? undefined;
+ throw new StalkerPortalError('auth-failed', failureBody, failureBody);
+ }
+}
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts
new file mode 100644
index 000000000..371196d3c
--- /dev/null
+++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts
@@ -0,0 +1,107 @@
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { DataService, PlaylistsService } from '@iptvnator/services';
+import type { Playlist } from '@iptvnator/shared/interfaces';
+import { StalkerSessionService } from './stalker-session.service';
+
+describe('Stalker edited-session coordination', () => {
+ const oldPlaylist = {
+ _id: 'portal-edit',
+ title: 'Portal',
+ portalUrl: 'https://old.example.com/server/load.php',
+ macAddress: '00:1A:79:AA:BB:CC',
+ isFullStalkerPortal: true,
+ lastUsage: '',
+ } as Playlist;
+
+ let authenticate: jest.SpyInstance;
+ let resolveOldAuthentication: (
+ value: Awaited>
+ ) => void = () => undefined;
+ let service: StalkerSessionService;
+ let updatePlaylistMeta: jest.Mock;
+ let updateStalkerSession: jest.Mock;
+
+ beforeEach(() => {
+ updatePlaylistMeta = jest.fn(() => of(oldPlaylist));
+ updateStalkerSession = jest.fn(() => of(oldPlaylist));
+ TestBed.configureTestingModule({
+ providers: [
+ StalkerSessionService,
+ {
+ provide: DataService,
+ useValue: { sendIpcEvent: jest.fn() },
+ },
+ {
+ provide: PlaylistsService,
+ useValue: {
+ getPlaylistById: jest.fn(() => of(oldPlaylist)),
+ updatePlaylistMeta,
+ updateStalkerSession,
+ },
+ },
+ ],
+ });
+ service = TestBed.inject(StalkerSessionService);
+ authenticate = jest.spyOn(service, 'authenticate').mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveOldAuthentication = resolve;
+ })
+ );
+ });
+
+ it('prevents late pre-edit auth from replacing a resolved full session', async () => {
+ const oldAuthentication = service.ensureToken(oldPlaylist);
+ while (authenticate.mock.calls.length === 0) {
+ await Promise.resolve();
+ }
+ const editedPlaylist = {
+ ...oldPlaylist,
+ portalUrl: 'https://new.example.com/server/load.php',
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ stalkerTimeslot: 5,
+ };
+ const replacement = service.replaceSessionAfterEdit(editedPlaylist);
+
+ resolveOldAuthentication({ token: 'OLD_TOKEN' });
+
+ await expect(oldAuthentication).rejects.toThrow(/stale/i);
+ await replacement;
+ expect(service.getCachedToken(oldPlaylist._id)).toBe('NEW_TOKEN');
+ expect(updateStalkerSession).toHaveBeenLastCalledWith(
+ oldPlaylist._id,
+ expect.objectContaining({ stalkerToken: 'NEW_TOKEN' })
+ );
+ });
+
+ it('prevents late pre-edit auth from restoring a cleared simple session', async () => {
+ const oldAuthentication = service.ensureToken(oldPlaylist);
+ while (authenticate.mock.calls.length === 0) {
+ await Promise.resolve();
+ }
+ const editedPlaylist = {
+ ...oldPlaylist,
+ portalUrl: 'https://new.example.com/portal.php',
+ isFullStalkerPortal: false,
+ stalkerToken: undefined,
+ stalkerSessionIdentity: undefined,
+ stalkerWatchdogTimeout: undefined,
+ stalkerTimeslot: undefined,
+ };
+ const replacement = service.replaceSessionAfterEdit(editedPlaylist);
+
+ resolveOldAuthentication({ token: 'OLD_TOKEN' });
+
+ await expect(oldAuthentication).rejects.toThrow(/stale/i);
+ await replacement;
+ expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
+ expect(updatePlaylistMeta).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ _id: oldPlaylist._id,
+ stalkerSessionPatch: null,
+ })
+ );
+ });
+});
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts
new file mode 100644
index 000000000..c7557d826
--- /dev/null
+++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts
@@ -0,0 +1,108 @@
+import { firstValueFrom } from 'rxjs';
+import {
+ isFullStalkerPortalPlaylist,
+ type Playlist,
+ type PlaylistMetaUpdate,
+} from '@iptvnator/shared/interfaces';
+import type { PlaylistsService } from '@iptvnator/services';
+import { stalkerSessionFingerprint } from './stalker-session-store';
+import type { StalkerTokenCache } from './stalker-token-cache';
+import type { StalkerWatchdogController } from './stalker-watchdog.controller';
+
+/** Serializes authoritative Edit results against pre-edit authentication. */
+export class StalkerEditedSessionCoordinator {
+ private readonly authoritativeFingerprints = new Map();
+ private readonly replacements = new Map>();
+
+ constructor(
+ private readonly tokens: StalkerTokenCache,
+ private readonly watchdog: StalkerWatchdogController,
+ private readonly resolvePlaylistsService: () => PlaylistsService
+ ) {}
+
+ markAuthoritative(playlist: Playlist): string {
+ const fingerprint = stalkerSessionFingerprint(playlist);
+ this.authoritativeFingerprints.set(playlist._id, fingerprint);
+ return fingerprint;
+ }
+
+ async guard(playlist: Playlist): Promise {
+ const fingerprint = stalkerSessionFingerprint(playlist);
+ this.assertCurrent(playlist._id, fingerprint);
+ const replacement = this.replacements.get(playlist._id);
+ if (replacement) {
+ await replacement;
+ this.assertCurrent(playlist._id, fingerprint);
+ }
+ return fingerprint;
+ }
+
+ assertCurrent(playlistId: string, fingerprint: string): void {
+ const authoritative = this.authoritativeFingerprints.get(playlistId);
+ if (authoritative && authoritative !== fingerprint) {
+ throw new Error('Stale Stalker playlist configuration');
+ }
+ }
+
+ replace(playlist: Playlist): Promise {
+ const playlistId = playlist._id;
+ const fingerprint = this.markAuthoritative(playlist);
+ const previous = this.replacements.get(playlistId) ?? Promise.resolve();
+ const replacement = previous
+ .catch(() => undefined)
+ .then(() => this.commit(playlist, fingerprint));
+ this.replacements.set(playlistId, replacement);
+ void replacement
+ .finally(() => {
+ if (this.replacements.get(playlistId) === replacement) {
+ this.replacements.delete(playlistId);
+ }
+ })
+ .catch(() => undefined);
+ return replacement;
+ }
+
+ private async commit(
+ playlist: Playlist,
+ fingerprint: string
+ ): Promise {
+ const playlistId = playlist._id;
+ this.assertCurrent(playlistId, fingerprint);
+ await this.tokens
+ .getPending(playlistId)
+ ?.promise.catch(() => undefined);
+ this.assertCurrent(playlistId, fingerprint);
+
+ const playlists = this.resolvePlaylistsService();
+ if (!isFullStalkerPortalPlaylist(playlist)) {
+ this.tokens.clear(playlistId);
+ await firstValueFrom(
+ playlists.updatePlaylistMeta({
+ ...playlist,
+ stalkerSessionPatch: null,
+ } as PlaylistMetaUpdate)
+ );
+ return;
+ }
+
+ if (!playlist.stalkerToken) {
+ throw new Error(
+ 'Resolved full Stalker portal is missing a session token'
+ );
+ }
+
+ this.tokens.set(playlistId, playlist.stalkerToken, fingerprint);
+ this.watchdog.applyProfileTiming(playlistId, {
+ watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
+ timeslotSeconds: playlist.stalkerTimeslot,
+ });
+ await firstValueFrom(
+ playlists.updateStalkerSession(playlistId, {
+ stalkerToken: playlist.stalkerToken,
+ stalkerSessionIdentity: fingerprint,
+ stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
+ stalkerTimeslot: playlist.stalkerTimeslot,
+ })
+ );
+ }
+}
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts
index 8b99a9e2d..4c430faa7 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts
@@ -54,7 +54,9 @@ describe('buildStalkerEndpointCandidates', () => {
it('keeps a nonstandard pasted endpoint as the first candidate', () => {
expect(
- buildStalkerEndpointCandidates('http://portal.example/cp/portal.php')
+ buildStalkerEndpointCandidates(
+ 'http://portal.example/cp/portal.php'
+ )
).toEqual([
'http://portal.example/cp/portal.php',
'http://portal.example/cp/server/load.php',
@@ -62,7 +64,7 @@ describe('buildStalkerEndpointCandidates', () => {
]);
});
- it('derives standard fallbacks from a nonstandard endpoint\'s directory', () => {
+ it("derives standard fallbacks from a nonstandard endpoint's directory", () => {
// The pasted endpoint keeps the first shot, but recovery candidates
// must be its SIBLINGS — not paths appended to the file itself.
expect(
@@ -319,9 +321,7 @@ describe('classifyStalkerProbeResponse', () => {
expect(classifyStalkerProbeResponse({ js: false })).toBe(
'not-a-portal'
);
- expect(classifyStalkerProbeResponse({ js: null })).toBe(
- 'not-a-portal'
- );
+ expect(classifyStalkerProbeResponse({ js: null })).toBe('not-a-portal');
expect(classifyStalkerProbeResponse({ js: {} })).toBe('not-a-portal');
});
@@ -368,6 +368,12 @@ describe('getStalkerRequestErrorStatus', () => {
});
describe('legacyTransformStalkerPortalUrl', () => {
+ it('uses portal.php for an offline bare host', () => {
+ expect(legacyTransformStalkerPortalUrl('http://x.example')).toBe(
+ 'http://x.example/portal.php'
+ );
+ });
+
it('keeps the historical rewrites for the unreachable-host fallback', () => {
expect(legacyTransformStalkerPortalUrl('http://x.example/c')).toBe(
'http://x.example/portal.php'
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts
index c7797dd4f..b5e793438 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts
@@ -94,7 +94,9 @@ export function buildStalkerEndpointCandidates(rawUrl: string): string[] {
// ends in /stalker_portal — nesting it again would probe a path no
// server has.
if (!/\/stalker_portal(\/|$)/i.test(base)) {
- candidates.push(candidateFrom(`${base}/stalker_portal/server/load.php`));
+ candidates.push(
+ candidateFrom(`${base}/stalker_portal/server/load.php`)
+ );
}
return [...new Set(candidates)];
@@ -112,7 +114,8 @@ export {
isStalkerAuthFailureResponse,
} from '@iptvnator/shared/interfaces';
-export type StalkerProbeClassification = 'data' | 'auth-required' | 'not-a-portal';
+export type StalkerProbeClassification =
+ 'data' | 'auth-required' | 'not-a-portal';
/**
* Classifies what a token-less content request got back from a candidate
@@ -208,6 +211,15 @@ export function isStalkerProbeTimeout(error: unknown): boolean {
export function legacyTransformStalkerPortalUrl(url: string): string {
url = url.replace(/\/+$/, '');
+ try {
+ if (new URL(url).pathname === '/') {
+ return `${url}/portal.php`;
+ }
+ } catch {
+ // The import form validates URLs before this fallback runs. Preserve
+ // the historical best-effort behavior for direct helper callers.
+ }
+
if (url.endsWith('/c')) {
if (url.includes('/stalker_portal')) {
return url.replace(
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts
index 27e066a7f..7128c6e01 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
-import { of, throwError } from 'rxjs';
+import { of, Subject, throwError } from 'rxjs';
import type { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -36,6 +36,7 @@ describe('StalkerPortalRepairService', () => {
let persistError: Error | null;
let setCachedToken: jest.Mock;
let adoptDiscoveredSession: jest.Mock;
+ let adoptDiscoveredSimplePortal: jest.Mock;
let clearCachedToken: jest.Mock;
let refreshActiveWatchdogPlaylist: jest.Mock;
@@ -63,6 +64,7 @@ describe('StalkerPortalRepairService', () => {
});
setCachedToken = jest.fn();
adoptDiscoveredSession = jest.fn();
+ adoptDiscoveredSimplePortal = jest.fn();
clearCachedToken = jest.fn();
refreshActiveWatchdogPlaylist = jest.fn();
@@ -84,6 +86,7 @@ describe('StalkerPortalRepairService', () => {
useValue: {
setCachedToken,
adoptDiscoveredSession,
+ adoptDiscoveredSimplePortal,
clearCachedToken,
refreshActiveWatchdogPlaylist,
},
@@ -97,7 +100,10 @@ describe('StalkerPortalRepairService', () => {
describe('shouldAttemptRepair', () => {
it('triggers on the middleware plain-text auth bodies', () => {
expect(
- service.shouldAttemptRepair(MISCLASSIFIED, 'Authorization failed.')
+ service.shouldAttemptRepair(
+ MISCLASSIFIED,
+ 'Authorization failed.'
+ )
).toBe(true);
expect(
service.shouldAttemptRepair(
@@ -175,9 +181,9 @@ describe('StalkerPortalRepairService', () => {
status: 500,
})
).toBe(false);
- expect(
- service.shouldAttemptRepair(MISCLASSIFIED, { js: [] })
- ).toBe(false);
+ expect(service.shouldAttemptRepair(MISCLASSIFIED, { js: [] })).toBe(
+ false
+ );
});
it('never triggers for playlists without portal coordinates', () => {
@@ -484,6 +490,63 @@ describe('StalkerPortalRepairService', () => {
);
});
+ it('retires a remembered endpoint override before explicit Edit installs its result', async () => {
+ const wrongEndpoint = {
+ ...MISCLASSIFIED,
+ portalUrl: 'http://ministra.example/portal.php',
+ } as PlaylistMeta;
+ persistedRow = wrongEndpoint as Playlist;
+ discover.mockResolvedValue({
+ status: 'resolved',
+ portalUrl: 'http://ministra.example/server/load.php',
+ isFullStalkerPortal: true,
+ token: 'TOKEN2',
+ });
+ await service.repairPortal(wrongEndpoint);
+ expect(service.applyOverride(wrongEndpoint).portalUrl).toBe(
+ 'http://ministra.example/server/load.php'
+ );
+
+ service.retireForPlaylistEdit(wrongEndpoint._id);
+
+ expect(service.applyOverride(wrongEndpoint)).toBe(wrongEndpoint);
+ expect(clearCachedToken).toHaveBeenLastCalledWith('portal-1');
+ });
+
+ it('discards a repair verified before explicit Edit but completed after it', async () => {
+ const wrongEndpoint = {
+ ...MISCLASSIFIED,
+ portalUrl: 'http://ministra.example/portal.php',
+ } as PlaylistMeta;
+ persistedRow = wrongEndpoint as Playlist;
+ discover.mockResolvedValue({
+ status: 'resolved',
+ portalUrl: 'http://ministra.example/server/load.php',
+ isFullStalkerPortal: true,
+ token: 'REPAIR_TOKEN',
+ });
+ const writeCompletion = new Subject();
+ transformPlaylistMeta.mockImplementation((_id, transform) => {
+ writtenRow = transform(persistedRow) as Playlist | null;
+ return writeCompletion;
+ });
+
+ const repair = service.repairPortal(wrongEndpoint);
+ while (transformPlaylistMeta.mock.calls.length === 0) {
+ await Promise.resolve();
+ }
+ // The transform has verified A and computed B, but its async
+ // persistence has not completed. Explicit Edit C must fence all
+ // repair-side runtime/session effects that follow that await.
+ service.retireForPlaylistEdit(wrongEndpoint._id);
+ writeCompletion.next(writtenRow);
+
+ await expect(repair).resolves.toBeNull();
+ expect(service.applyOverride(wrongEndpoint)).toBe(wrongEndpoint);
+ expect(adoptDiscoveredSession).not.toHaveBeenCalled();
+ expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
+ });
+
it('discards an in-flight repair when the row was edited during the probe', async () => {
// The probe can run for tens of seconds; a user who saves a new
// portal URL meanwhile must win over the repair of the old one.
@@ -724,9 +787,9 @@ describe('StalkerPortalRepairService', () => {
const repaired = await service.repairPortal(MISCLASSIFIED);
expect(repaired?.isFullStalkerPortal).toBe(true);
- expect(service.applyOverride(MISCLASSIFIED).isFullStalkerPortal).toBe(
- true
- );
+ expect(
+ service.applyOverride(MISCLASSIFIED).isFullStalkerPortal
+ ).toBe(true);
});
it('clears a stale cached token when a portal turns out token-free', async () => {
@@ -745,7 +808,13 @@ describe('StalkerPortalRepairService', () => {
const repaired = await service.repairPortal(wronglyFull);
expect(repaired?.isFullStalkerPortal).toBe(false);
- expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
+ expect(adoptDiscoveredSimplePortal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ _id: 'portal-1',
+ portalUrl: 'http://panel.example/portal.php',
+ isFullStalkerPortal: false,
+ })
+ );
});
});
});
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts
index 617bddf77..b7f4b48eb 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts
@@ -41,8 +41,6 @@ interface StalkerPortalModeOverride {
isFullStalkerPortal: boolean;
}
-
-
/**
* Lazy repair for playlists whose persisted portal endpoint or mode is
* wrong. The flag used to be a URL-shape guess frozen at import, so a
@@ -99,6 +97,8 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
string,
Promise
>();
+ /** Explicit Edit invalidates every repair that started before it. */
+ private readonly editGenerations = new Map();
constructor() {
// The watchdog resolves its playlist from the persisted row; while a
@@ -134,7 +134,8 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
}
if (
- stalkerIdentityFingerprint(playlist) !== override.identityFingerprint
+ stalkerIdentityFingerprint(playlist) !==
+ override.identityFingerprint
) {
// The MAC or Stalker identity was edited after the repair. The
// override AND the token the repair authenticated for the
@@ -171,6 +172,19 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
return playlist;
}
+ /**
+ * Retires a lazy-repair override before an explicit Edit result is
+ * installed. Discovery has just proved that exact endpoint and mode, so
+ * an override remembered for the pre-edit connection must not rewrite it.
+ */
+ retireForPlaylistEdit(playlistId: string): void {
+ this.editGenerations.set(
+ playlistId,
+ (this.editGenerations.get(playlistId) ?? 0) + 1
+ );
+ this.dropOverride(playlistId);
+ }
+
/**
* Retires the session artifacts a repair installed for a playlist: the
* ACTIVE override and the cached token (authenticated for the pre-edit
@@ -258,8 +272,7 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
const fingerprint = this.repairSourceFingerprint(playlist);
const history = this.probeHistory.get(playlistId) ?? new Map();
const record = history.get(fingerprint) as
- | StalkerProbeRecord
- | undefined;
+ StalkerProbeRecord | undefined;
if (record === 'discarded') {
// The probe for this configuration was discarded because the
// row had moved on mid-probe. If the row has since been
@@ -301,7 +314,10 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
// produced override or the 'discarded' marker.
history.set(fingerprint, 'no-change');
this.probeHistory.set(playlistId, history);
- const run = this.runRepair(playlist);
+ const run = this.runRepair(
+ playlist,
+ this.editGenerations.get(playlistId) ?? 0
+ );
this.pendingRepairs.set(playlistId, run);
try {
return await run;
@@ -336,7 +352,8 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
}
private async runRepair(
- playlist: PlaylistMeta
+ playlist: PlaylistMeta,
+ editGeneration: number
): Promise {
const outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
@@ -362,6 +379,10 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
return null;
}
+ if (this.editGenerationChanged(playlist._id, editGeneration)) {
+ return this.discardSupersededRepair(playlist);
+ }
+
const storedMode = isFullStalkerPortalPlaylist(playlist);
if (
outcome.portalUrl === playlist.portalUrl &&
@@ -385,7 +406,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
this.injector
.get(PlaylistsService)
.transformPlaylistMeta(playlist._id, (row) => {
- if (!this.rowMatchesSource(row, playlist, storedMode)) {
+ if (
+ this.editGenerationChanged(
+ playlist._id,
+ editGeneration
+ ) ||
+ !this.rowMatchesSource(row, playlist, storedMode)
+ ) {
return null;
}
verifiedAgainstRow = true;
@@ -400,23 +427,14 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
// A failed WRITE after successful verification keeps the
// session-only override below; a failed READ means the premise
// could not be verified and the repair is discarded.
- this.logger.warn(
- 'Persisting repaired portal mode failed',
- error
- );
+ this.logger.warn('Persisting repaired portal mode failed', error);
}
- if (!verifiedAgainstRow) {
- this.logger.info(
- 'Portal configuration changed while probing; discarding repair'
- );
- // Marked explicitly: a later failure of this configuration may
- // probe again once the row is RESTORED to it — unlike a probe
- // whose outcome genuinely applied ('no-change'/override).
- this.probeHistory
- .get(playlist._id)
- ?.set(this.repairSourceFingerprint(playlist), 'discarded');
- return null;
+ if (
+ !verifiedAgainstRow ||
+ this.editGenerationChanged(playlist._id, editGeneration)
+ ) {
+ return this.discardSupersededRepair(playlist);
}
const override: StalkerPortalModeOverride = {
@@ -450,7 +468,9 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
}
);
} else if (!outcome.isFullStalkerPortal) {
- this.stalkerSession.clearCachedToken(playlist._id);
+ this.stalkerSession.adoptDiscoveredSimplePortal(
+ toStalkerSessionPlaylist(this.applyOverride(playlist))
+ );
}
// If this playlist currently owns the watchdog, re-sync it with the
@@ -468,6 +488,26 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
return this.applyOverride(playlist);
}
+ private editGenerationChanged(
+ playlistId: string,
+ expected: number
+ ): boolean {
+ return (this.editGenerations.get(playlistId) ?? 0) !== expected;
+ }
+
+ private discardSupersededRepair(
+ playlist: PlaylistMeta
+ ): PlaylistMeta | null {
+ this.logger.info(
+ 'Portal configuration changed while probing; discarding repair'
+ );
+ // A later failure may probe again if this source is restored.
+ this.probeHistory
+ .get(playlist._id)
+ ?.set(this.repairSourceFingerprint(playlist), 'discarded');
+ return null;
+ }
+
/**
* Cheap gate for retrying a DISCARDED configuration: reads the current
* row and reports whether it now carries the caller's configuration.
@@ -516,5 +556,4 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
(row.password ?? '') === (playlist.password ?? '')
);
}
-
}
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 86e53d0e6..60a49f511 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
@@ -243,6 +243,31 @@ describe('StalkerSessionService identity-tagged token cache', () => {
expect(service.getCachedToken('portal-1')).not.toBe('EXPIRED');
});
+ it('preserves HTTP status evidence when the authenticated retry also fails', async () => {
+ const forbidden = {
+ message: 'HTTP Error 403: Forbidden',
+ status: 403,
+ };
+ service.setCachedToken('portal-1', 'EXPIRED', playlistA);
+ sendIpcEvent
+ .mockRejectedValueOnce(forbidden)
+ .mockResolvedValueOnce({
+ js: { token: 'FRESH', random: 'r' },
+ })
+ .mockResolvedValueOnce({ js: {} })
+ .mockRejectedValueOnce(forbidden);
+
+ await expect(
+ service.makeAuthenticatedRequest(playlistA, {
+ action: 'get_genres',
+ })
+ ).rejects.toBe(forbidden);
+
+ // StalkerPortalRepairService consumes this exact transport evidence
+ // to decide that endpoint discovery is justified.
+ expect(forbidden.status).toBe(403);
+ });
+
it.each(['Access denied.', 'Unauthorized request.'])(
'retires the token for the %j plain-text failure too',
async (body) => {
@@ -934,8 +959,7 @@ describe('StalkerSessionService identity payloads', () => {
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-17',
expect.objectContaining({
- stalkerWatchdogTimeout:
- STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
+ stalkerWatchdogTimeout: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
stalkerTimeslot: 0,
})
);
@@ -1032,7 +1056,8 @@ describe('StalkerSessionService identity payloads', () => {
expect(
stalkerSessionFingerprint({
...portal,
- portalUrl: 'https://panel.example.com/tenant-a/server/load.php/',
+ portalUrl:
+ 'https://panel.example.com/tenant-a/server/load.php/',
} as Playlist)
).toBe(stalkerSessionFingerprint(portal));
});
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
index 2af5538eb..c0ef13907 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
@@ -1,11 +1,9 @@
import { Injectable, Injector, inject } from '@angular/core';
import {
- extractStalkerAuthFailureBody,
isFullStalkerPortalPlaylist,
isFullStalkerPortalUrl,
Playlist,
PlaylistMeta,
- STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
@@ -24,8 +22,8 @@ import {
type StalkerPortalCredentials,
type StalkerProfileResponse,
} from './stalker-auth.api';
-import { isStalkerAuthorizationFailure } from './stalker-response-classification';
-import { StalkerPortalError } from './stalker-portal-error';
+import { StalkerAuthenticatedRequestClient } from './stalker-authenticated-request-client';
+import { StalkerEditedSessionCoordinator } from './stalker-edited-session-coordinator';
import {
stalkerSessionFingerprint,
StalkerSessionStore,
@@ -72,7 +70,10 @@ export class StalkerSessionService {
// when a session is resolved from (or written back to) the stored row.
private readonly injector = inject(Injector);
private readonly logger = createLogger('StalkerSession');
- private readonly authApi = new StalkerAuthApi(this.dataService, this.logger);
+ private readonly authApi = new StalkerAuthApi(
+ this.dataService,
+ this.logger
+ );
private readonly sessionStore = new StalkerSessionStore(
() => this.injector.get(PlaylistsService),
this.logger
@@ -88,6 +89,16 @@ export class StalkerSessionService {
// Session state for this run, identity-tagged so an edited playlist can
// inherit neither a cached token nor an in-flight authentication.
private readonly tokens = new StalkerTokenCache();
+ private readonly editedSessions = new StalkerEditedSessionCoordinator(
+ this.tokens,
+ this.watchdog,
+ () => this.injector.get(PlaylistsService)
+ );
+ private readonly requestClient = new StalkerAuthenticatedRequestClient(
+ this.dataService,
+ this.tokens,
+ (playlist) => this.ensureToken(playlist)
+ );
/**
* Checks if a URL looks like a full stalker portal URL (requires
@@ -191,6 +202,8 @@ export class StalkerSessionService {
timeslotSeconds?: number;
}
): void {
+ const fingerprint =
+ this.editedSessions.markAuthoritative(identitySource);
this.setCachedToken(playlistId, session.token, identitySource);
this.applySessionOutcome(
playlistId,
@@ -206,10 +219,29 @@ export class StalkerSessionService {
watchdogTimeoutSeconds: identitySource.stalkerWatchdogTimeout,
timeslotSeconds: identitySource.stalkerTimeslot,
},
- stalkerSessionFingerprint(identitySource)
+ fingerprint
);
}
+ /** Retires a full-portal session after discovery proves a simple portal. */
+ adoptDiscoveredSimplePortal(identitySource: Playlist): void {
+ this.editedSessions.markAuthoritative(identitySource);
+ this.clearCachedToken(identitySource._id);
+ }
+
+ /**
+ * Installs the session produced by explicit Edit discovery.
+ *
+ * The new fingerprint becomes authoritative synchronously, before an old
+ * authentication is awaited. That old request consequently fails its
+ * post-auth guard and cannot overwrite the resolved token (or restore a
+ * token after a full→simple edit). The final write is queued after it so
+ * the resolved session remains the last persisted session mutation.
+ */
+ replaceSessionAfterEdit(playlist: Playlist): Promise {
+ return this.editedSessions.replace(playlist);
+ }
+
/**
* Performs handshake to get a session token for a full stalker portal.
* An optional persisted token is re-presented: the handshake is
@@ -300,6 +332,8 @@ export class StalkerSessionService {
async ensureToken(
playlist: Playlist
): Promise<{ token: string | null; serialNumber?: string }> {
+ const fingerprint = await this.editedSessions.guard(playlist);
+
// If not a full stalker portal, no token needed
if (!isFullStalkerPortalPlaylist(playlist)) {
return { token: null };
@@ -309,8 +343,6 @@ export class StalkerSessionService {
// One key for both caches: endpoint + identity + credentials. An
// identity-only in-run key would hand the cached bearer token to a
// freshly edited endpoint before the persisted check ever ran.
- const fingerprint = stalkerSessionFingerprint(playlist);
-
// Only the session negotiated for THIS identity may be reused.
const cachedToken = this.tokens.takeFor(playlist._id, fingerprint);
if (cachedToken) {
@@ -376,6 +408,7 @@ export class StalkerSessionService {
stored.watchdogTimeoutSeconds !== undefined,
}
);
+ this.editedSessions.assertCurrent(playlist._id, fingerprint);
this.setCachedToken(playlist._id, result.token, playlist);
this.applySessionOutcome(
playlist._id,
@@ -426,7 +459,7 @@ export class StalkerSessionService {
// One key for both caches: endpoint + identity + credentials. An
// identity-only in-run key would hand the cached bearer token to a
// freshly edited endpoint before the persisted check ever ran.
- const fingerprint = stalkerSessionFingerprint(playlist);
+ const fingerprint = await this.editedSessions.guard(playlist);
// Claim the per-playlist slot. Re-check after every await: one
// settled promise releases every waiter at once, so a single
@@ -441,6 +474,7 @@ export class StalkerSessionService {
// performs its own handshake either way.
await inFlight.promise.catch(() => undefined);
}
+ this.editedSessions.assertCurrent(playlist._id, fingerprint);
// Publish the slot before the first await so no other waiter can
// observe it as free while this handshake is starting.
@@ -481,6 +515,7 @@ export class StalkerSessionService {
},
}
);
+ this.editedSessions.assertCurrent(playlist._id, fingerprint);
this.setCachedToken(playlist._id, result.token, playlist);
// This path always ran a real get_profile, so the decoded cadence
// is authoritative; the previous values are only the write-back
@@ -536,68 +571,10 @@ export class StalkerSessionService {
params: Record,
retryOnAuthFailure = true
): Promise {
- // Get token (will wait if auth is in progress)
- const { token, serialNumber } = await this.ensureToken(playlist);
-
- try {
- const response = await this.dataService.sendIpcEvent(
- STALKER_REQUEST,
- {
- url: playlist.portalUrl,
- macAddress: playlist.macAddress,
- params,
- token,
- ...(serialNumber ? { serialNumber } : {}),
- }
- );
-
- // Check for authorization failure in response
- if (isStalkerAuthorizationFailure(response)) {
- // Retire the failed token even when not retrying (e.g.
- // watchdog pings): leaving it cached would hand a dead
- // session to the next caller.
- this.tokens.retireFailed(playlist._id, token);
- if (
- retryOnAuthFailure &&
- isFullStalkerPortalPlaylist(playlist)
- ) {
- // Retry once with fresh authentication
- return this.makeAuthenticatedRequest(
- playlist,
- params,
- false
- );
- }
-
- const failureBody =
- extractStalkerAuthFailureBody(response) ?? undefined;
- throw new StalkerPortalError(
- 'auth-failed',
- failureBody,
- failureBody
- );
- }
-
- return response;
- } catch (error) {
- // Check if error indicates auth failure
- if (isStalkerAuthorizationFailure(error)) {
- // Same rule as above: a failed session is retired even on
- // the no-retry path.
- this.tokens.retireFailed(playlist._id, token);
- if (
- retryOnAuthFailure &&
- isFullStalkerPortalPlaylist(playlist)
- ) {
- // Retry with a fresh handshake
- return this.makeAuthenticatedRequest(
- playlist,
- params,
- false
- );
- }
- }
- throw error;
- }
+ return this.requestClient.request(
+ playlist,
+ params,
+ retryOnAuthFailure
+ );
}
}
diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts
index dbf621fa1..adc59f94b 100644
--- a/libs/services/src/lib/playlists.service.spec.ts
+++ b/libs/services/src/lib/playlists.service.spec.ts
@@ -42,8 +42,7 @@ describe('PlaylistsService', () => {
runtime: {
get supportsSqlite() {
const electron = testWindow.electron as
- | Record
- | undefined;
+ Record | undefined;
return (
!!electron &&
typeof electron['dbGetAppPlaylists'] === 'function' &&
@@ -587,6 +586,121 @@ describe('PlaylistsService', () => {
);
});
+ describe('Stalker session patches in playlist meta updates', () => {
+ function createStalkerPlaylist(): Playlist {
+ return {
+ _id: 'stalker-session-patch',
+ title: 'Stalker Portal',
+ count: 0,
+ importDate: '2026-08-08T00:00:00.000Z',
+ lastUsage: '2026-08-08T00:00:00.000Z',
+ autoRefresh: false,
+ stalkerToken: 'OLD_TOKEN',
+ stalkerSessionIdentity: 'old-fingerprint',
+ stalkerWatchdogTimeout: 120,
+ stalkerTimeslot: 15,
+ stalkerAccountInfo: {
+ login: 'old-login',
+ expireDate: 1800000000,
+ },
+ };
+ }
+
+ it('preserves the current session when the transient patch is absent', async () => {
+ const existingPlaylist = createStalkerPlaylist();
+ const dbService = {
+ getAll: jest.fn(() => of([])),
+ getByID: jest.fn(() => of(existingPlaylist)),
+ update: jest.fn((_storeName: string, playlist: Playlist) =>
+ of(playlist)
+ ),
+ };
+ testWindow.electron = undefined;
+ const service = createService(dbService);
+
+ await firstValueFrom(
+ service.updatePlaylistMeta({
+ _id: existingPlaylist._id,
+ title: 'Renamed Portal',
+ } as PlaylistMeta)
+ );
+
+ expect(dbService.update.mock.calls[0][1]).toEqual(
+ expect.objectContaining({
+ stalkerToken: 'OLD_TOKEN',
+ stalkerSessionIdentity: 'old-fingerprint',
+ stalkerWatchdogTimeout: 120,
+ stalkerTimeslot: 15,
+ stalkerAccountInfo: existingPlaylist.stalkerAccountInfo,
+ })
+ );
+ });
+
+ it('clears every stored session field when the transient patch is null', async () => {
+ const existingPlaylist = createStalkerPlaylist();
+ const dbService = {
+ getAll: jest.fn(() => of([])),
+ getByID: jest.fn(() => of(existingPlaylist)),
+ update: jest.fn((_storeName: string, playlist: Playlist) =>
+ of(playlist)
+ ),
+ };
+ testWindow.electron = undefined;
+ const service = createService(dbService);
+
+ await firstValueFrom(
+ service.updatePlaylistMeta({
+ _id: existingPlaylist._id,
+ stalkerSessionPatch: null,
+ } as never)
+ );
+
+ const written = dbService.update.mock.calls[0][1];
+ expect(written.stalkerToken).toBeUndefined();
+ expect(written.stalkerSessionIdentity).toBeUndefined();
+ expect(written.stalkerWatchdogTimeout).toBeUndefined();
+ expect(written.stalkerTimeslot).toBeUndefined();
+ expect(written.stalkerAccountInfo).toBeUndefined();
+ expect(written).not.toHaveProperty('stalkerSessionPatch');
+ });
+
+ it('fully replaces session metadata without persisting the transient patch', async () => {
+ const existingPlaylist = createStalkerPlaylist();
+ const dbService = {
+ getAll: jest.fn(() => of([])),
+ getByID: jest.fn(() => of(existingPlaylist)),
+ update: jest.fn((_storeName: string, playlist: Playlist) =>
+ of(playlist)
+ ),
+ };
+ testWindow.electron = undefined;
+ const service = createService(dbService);
+
+ await firstValueFrom(
+ service.updatePlaylistMeta({
+ _id: existingPlaylist._id,
+ stalkerSessionPatch: {
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ },
+ } as never)
+ );
+
+ const written = dbService.update.mock.calls[0][1];
+ expect(written).toEqual(
+ expect.objectContaining({
+ stalkerToken: 'NEW_TOKEN',
+ stalkerSessionIdentity: 'new-fingerprint',
+ stalkerWatchdogTimeout: 90,
+ })
+ );
+ expect(written.stalkerTimeslot).toBeUndefined();
+ expect(written.stalkerAccountInfo).toBeUndefined();
+ expect(written).not.toHaveProperty('stalkerSessionPatch');
+ });
+ });
+
it('updates many browser playlists with refresh metadata', async () => {
jest.spyOn(Date, 'now').mockReturnValue(1770000000000);
// Auto-refresh snapshots always come from playlists that had
@@ -1662,7 +1776,10 @@ describe('PlaylistsService', () => {
'portal-meta-write',
(current) =>
current.title === 'Edited Title'
- ? { ...current, portalUrl: 'http://x/portal.php' }
+ ? {
+ ...current,
+ portalUrl: 'http://x/portal.php',
+ }
: null
)
),
diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts
index 942f980c8..0eddef59b 100644
--- a/libs/services/src/lib/playlists.service.ts
+++ b/libs/services/src/lib/playlists.service.ts
@@ -27,7 +27,7 @@ import {
M3uFavoriteChannel,
M3uRecentlyViewedItem,
Playlist,
- PlaylistMeta,
+ PlaylistMetaUpdate,
PlaylistRecentlyViewedItem,
PlaylistUpdateState,
StalkerPortalItem,
@@ -627,7 +627,7 @@ export class PlaylistsService {
);
}
- updatePlaylistMeta(updatedPlaylist: PlaylistMeta) {
+ updatePlaylistMeta(updatedPlaylist: PlaylistMetaUpdate) {
return this.serializePlaylistWrite(updatedPlaylist._id, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(updatedPlaylist._id)
@@ -730,6 +730,24 @@ export class PlaylistsService {
stalkerSignature2: updatedPlaylist.stalkerSignature2,
}
: {}),
+ ...(updatedPlaylist.stalkerSessionPatch !== undefined
+ ? {
+ stalkerToken:
+ updatedPlaylist.stalkerSessionPatch?.stalkerToken,
+ stalkerSessionIdentity:
+ updatedPlaylist.stalkerSessionPatch
+ ?.stalkerSessionIdentity,
+ stalkerWatchdogTimeout:
+ updatedPlaylist.stalkerSessionPatch
+ ?.stalkerWatchdogTimeout,
+ stalkerTimeslot:
+ updatedPlaylist.stalkerSessionPatch
+ ?.stalkerTimeslot,
+ stalkerAccountInfo:
+ updatedPlaylist.stalkerSessionPatch
+ ?.stalkerAccountInfo,
+ }
+ : {}),
};
return this.persistPlaylistMutation(nextPlaylist);
diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts
index e06cd9185..ade1f5fdc 100644
--- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts
+++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts
@@ -35,3 +35,23 @@ export type PlaylistMeta = Pick<
| 'stalkerSignature1'
| 'stalkerSignature2'
>;
+
+export interface StalkerPlaylistSessionMetadata {
+ stalkerToken: string;
+ stalkerSessionIdentity?: string;
+ stalkerWatchdogTimeout?: number;
+ stalkerTimeslot?: number;
+ stalkerAccountInfo?: Playlist['stalkerAccountInfo'];
+}
+
+/**
+ * Metadata update accepted by the persistence boundary.
+ *
+ * `stalkerSessionPatch` is transient: absence preserves the negotiated
+ * session, `null` clears it, and an object fully replaces it. The patch is
+ * projected onto the existing flat playlist fields and is never persisted as
+ * its own database or backup property.
+ */
+export interface PlaylistMetaUpdate extends PlaylistMeta {
+ stalkerSessionPatch?: StalkerPlaylistSessionMetadata | null;
+}