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) { +
+ {{ - 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate + 'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate }} - + - } - @if (playlist.macAddress) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate - }} - - {{ - 'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate - }} - - } - @if (playlist.portalUrl) { - @if (hasStoredStalkerDeviceIds) { -

- {{ - 'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING' - | translate - }} -

+ @if (playlist.portalUrl) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate + }} + + {{ + 'HOME.STALKER_PORTAL.URL_HINT' | translate + }} + {{ + 'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | 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.username) { - - {{ - 'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate - }} - - - } - @if (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) { - + @if (playlist.macAddress) { + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate + }} + + {{ + 'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate + }} + + } + @if (playlist.portalUrl) { + @if (hasStoredStalkerDeviceIds) { +

{{ - 'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' + 'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING' | translate }} - +

} -
- } - @if (canManagePlaylistEpgSources) { -
-
- event_note -
-

- {{ 'SETTINGS.EPG_SOURCES' | translate }} -

-

- @if (hiddenDetectedPlaylistEpgSourceCount > 0) { - {{ - 'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED' - | translate - : { - enabled: - playlistEpgUrls.length, - detected: - playlistDetectedEpgUrls.length, - } - }} - } @else { - {{ - 'SETTINGS.EPG_SOURCES_DESCRIPTION' - | translate - }} - } -

-
-
- @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) { +
- @for ( - control of playlistEpgSourceInputs.controls; - track $index - ) { +
+ event_note +
+

+ {{ 'SETTINGS.EPG_SOURCES' | translate }} +

+

+ @if (hiddenDetectedPlaylistEpgSourceCount > 0) { + {{ + 'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED' + | translate + : { + enabled: + playlistEpgUrls.length, + detected: + playlistDetectedEpgUrls.length, + } + }} + } @else { + {{ + 'SETTINGS.EPG_SOURCES_DESCRIPTION' + | translate + }} + } +

+
+
+ @for (epgUrl of playlistEpgUrls; track epgUrl) {
{{ 'SETTINGS.EPG_URL_LABEL' | translate }} - - @if (control.invalid && control.touched) { - {{ - 'SETTINGS.EPG_URL_ERROR' | translate - }} - } + + +
} -
- - +
+ @for ( + control of playlistEpgSourceInputs.controls; + track $index + ) { +
+ + {{ + 'SETTINGS.EPG_URL_LABEL' | translate + }} + + @if (control.invalid && control.touched) { + {{ + 'SETTINGS.EPG_URL_ERROR' | translate + }} + } + + +
+ } +
+ + +
-
-
- } - @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; +}