mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(stalker): discover portal connection on edit
This commit is contained in:
1 parent
c95f826739
commit
d2d91e322b
47 files changed
+2335
-497
No files matched your search
@@ -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<void> {
|
||||
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<void> {
|
||||
@@ -106,7 +114,7 @@ async function openSeededPortal(page: Page, title: string): Promise<void> {
|
||||
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`,
|
||||
|
||||
@@ -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(),
|
||||
{
|
||||
|
||||
@@ -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<void>((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'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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<StalkerPortalErrorKind, string>
|
||||
> = {
|
||||
'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<void> {
|
||||
// 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<StalkerPlaylistConnectionResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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": "رفضت البوابة الوصول لهذا الجهاز.",
|
||||
|
||||
@@ -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": "البوابة رفضات الوصول لهاد الجهاز.",
|
||||
|
||||
@@ -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": "Партал адмовіў у доступе для гэтай прылады.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "ポータルがこのデバイスのアクセスを拒否しました。",
|
||||
|
||||
@@ -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": "포털이 이 기기의 접근을 거부했습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Портал отказал в доступе этому устройству.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "门户拒绝了此设备的访问。",
|
||||
|
||||
@@ -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": "入口網站拒絕了此裝置的存取。",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
+3
-3
@@ -15,10 +15,10 @@
|
||||
id="portalUrl"
|
||||
formControlName="portalUrl"
|
||||
/>
|
||||
<mat-hint>{{
|
||||
<mat-hint>{{ 'HOME.STALKER_PORTAL.URL_HINT' | translate }}</mat-hint>
|
||||
<mat-error>{{
|
||||
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
|
||||
}}</mat-hint>
|
||||
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label for="macAddress">{{
|
||||
|
||||
+48
-11
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+7
-6
@@ -92,7 +92,7 @@ interface StalkerSettledIdentity {
|
||||
})
|
||||
export class StalkerPortalImportComponent {
|
||||
readonly addClicked = output<void>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
+336
-299
@@ -5,249 +5,214 @@
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
|
||||
</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="title" matInput />
|
||||
</mat-form-field>
|
||||
@if (playlist.portalUrl) {
|
||||
<fieldset
|
||||
class="playlist-info-fields"
|
||||
[disabled]="isSaving()"
|
||||
[attr.aria-busy]="isSaving()"
|
||||
>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="portalUrl" />
|
||||
<input formControlName="title" matInput />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.macAddress) {
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="macAddress"
|
||||
(blur)="onMacAddressBlur()"
|
||||
/>
|
||||
<mat-error>{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl) {
|
||||
@if (hasStoredStalkerDeviceIds) {
|
||||
<p class="stalker-device-id-warning">
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
@if (playlist.portalUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="portalUrl" />
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.URL_HINT' | translate
|
||||
}}</mat-hint>
|
||||
<mat-error>{{
|
||||
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSerialNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature2" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.serverUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="serverUrl" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.username) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="username" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.password) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="password" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.url) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="url" />
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="copyUrl()"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filename) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="filename" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="importDate" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.count) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="count" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop) {
|
||||
<mat-form-field class="w-full mb-10">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="userAgent" matInput />
|
||||
<mat-hint>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filePath) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
|
||||
</mat-label>
|
||||
<input formControlName="filePath" matInput />
|
||||
@if (playlist.updateState === 2) {
|
||||
<mat-hint>
|
||||
@if (playlist.macAddress) {
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="macAddress"
|
||||
(blur)="onMacAddressBlur()"
|
||||
/>
|
||||
<mat-error>{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl) {
|
||||
@if (hasStoredStalkerDeviceIds) {
|
||||
<p class="stalker-device-id-warning">
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
|
||||
| translate
|
||||
}}
|
||||
</mat-hint>
|
||||
</p>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (canManagePlaylistEpgSources) {
|
||||
<section
|
||||
class="playlist-epg-sources"
|
||||
data-test-id="playlist-epg-sources"
|
||||
>
|
||||
<div class="playlist-epg-sources__header">
|
||||
<mat-icon>event_note</mat-icon>
|
||||
<div>
|
||||
<h3 class="playlist-epg-sources__title">
|
||||
{{ 'SETTINGS.EPG_SOURCES' | translate }}
|
||||
</h3>
|
||||
<p>
|
||||
@if (hiddenDetectedPlaylistEpgSourceCount > 0) {
|
||||
{{
|
||||
'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED'
|
||||
| translate
|
||||
: {
|
||||
enabled:
|
||||
playlistEpgUrls.length,
|
||||
detected:
|
||||
playlistDetectedEpgUrls.length,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
{{
|
||||
'SETTINGS.EPG_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@for (epgUrl of playlistEpgUrls; track epgUrl) {
|
||||
<div class="playlist-epg-source-row">
|
||||
<mat-form-field
|
||||
class="w-full"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput [value]="epgUrl" readonly />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="!canRefreshPlaylistEpg"
|
||||
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
|
||||
(click)="refreshPlaylistEpgSource(epgUrl)"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isGlobalEpgSource(epgUrl)"
|
||||
[matTooltip]="'SETTINGS.ADD_EPG_SOURCE' | translate"
|
||||
(click)="addPlaylistEpgSourceToSettings(epgUrl)"
|
||||
>
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
(click)="removePlaylistEpgSource(epgUrl)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
formArrayName="playlistEpgSourceInputs"
|
||||
class="playlist-epg-source-form"
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSerialNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature2" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.serverUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="serverUrl" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl || playlist.username) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="username" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl || playlist.password) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="password" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.url) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="url" />
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="copyUrl()"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filename) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME'
|
||||
| translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="filename" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="importDate" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.count) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="count" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop) {
|
||||
<mat-form-field class="w-full mb-10">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="userAgent" matInput />
|
||||
<mat-hint>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT'
|
||||
| translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filePath) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
|
||||
</mat-label>
|
||||
<input formControlName="filePath" matInput />
|
||||
@if (playlist.updateState === 2) {
|
||||
<mat-hint>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
|
||||
| translate
|
||||
}}
|
||||
</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (canManagePlaylistEpgSources) {
|
||||
<section
|
||||
class="playlist-epg-sources"
|
||||
data-test-id="playlist-epg-sources"
|
||||
>
|
||||
@for (
|
||||
control of playlistEpgSourceInputs.controls;
|
||||
track $index
|
||||
) {
|
||||
<div class="playlist-epg-sources__header">
|
||||
<mat-icon>event_note</mat-icon>
|
||||
<div>
|
||||
<h3 class="playlist-epg-sources__title">
|
||||
{{ 'SETTINGS.EPG_SOURCES' | translate }}
|
||||
</h3>
|
||||
<p>
|
||||
@if (hiddenDetectedPlaylistEpgSourceCount > 0) {
|
||||
{{
|
||||
'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED'
|
||||
| translate
|
||||
: {
|
||||
enabled:
|
||||
playlistEpgUrls.length,
|
||||
detected:
|
||||
playlistDetectedEpgUrls.length,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
{{
|
||||
'SETTINGS.EPG_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@for (epgUrl of playlistEpgUrls; track epgUrl) {
|
||||
<div class="playlist-epg-source-row">
|
||||
<mat-form-field
|
||||
class="w-full"
|
||||
@@ -256,87 +221,159 @@
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput [formControlName]="$index" />
|
||||
@if (control.invalid && control.touched) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
<input matInput [value]="epgUrl" readonly />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="!canRefreshPlaylistEpg"
|
||||
[matTooltip]="
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
(click)="refreshPlaylistEpgSource(epgUrl)"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isGlobalEpgSource(epgUrl)"
|
||||
[matTooltip]="
|
||||
'SETTINGS.ADD_EPG_SOURCE' | translate
|
||||
"
|
||||
(click)="addPlaylistEpgSourceToSettings(epgUrl)"
|
||||
>
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
(click)="removePlaylistEpgSourceInput($index)"
|
||||
(click)="removePlaylistEpgSource(epgUrl)"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="playlist-epg-source-actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="addPlaylistEpgSourceInput()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
('HOME.PLAYLISTS.INFO_DIALOG.SAVE'
|
||||
| translate) +
|
||||
' ' +
|
||||
('SETTINGS.EPG_SOURCES' | translate)
|
||||
"
|
||||
(click)="savePlaylistEpgSources()"
|
||||
>
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
|
||||
</button>
|
||||
<div
|
||||
formArrayName="playlistEpgSourceInputs"
|
||||
class="playlist-epg-source-form"
|
||||
>
|
||||
@for (
|
||||
control of playlistEpgSourceInputs.controls;
|
||||
track $index
|
||||
) {
|
||||
<div class="playlist-epg-source-row">
|
||||
<mat-form-field
|
||||
class="w-full"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[formControlName]="$index"
|
||||
/>
|
||||
@if (control.invalid && control.touched) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
(click)="
|
||||
removePlaylistEpgSourceInput($index)
|
||||
"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="playlist-epg-source-actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="addPlaylistEpgSourceInput()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
('HOME.PLAYLISTS.INFO_DIALOG.SAVE'
|
||||
| translate) +
|
||||
' ' +
|
||||
('SETTINGS.EPG_SOURCES' | translate)
|
||||
"
|
||||
(click)="savePlaylistEpgSources()"
|
||||
>
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SAVE'
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@if (isDesktop && (playlist.url || playlist.filePath)) {
|
||||
<mat-checkbox class="w-full" formControlName="autoRefresh">
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
|
||||
</mat-checkbox>
|
||||
<p>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!playlist.serverUrl && !playlist.portalUrl) {
|
||||
</section>
|
||||
}
|
||||
@if (isDesktop && (playlist.url || playlist.filePath)) {
|
||||
<mat-checkbox class="w-full" formControlName="autoRefresh">
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
|
||||
</mat-checkbox>
|
||||
<p>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!playlist.serverUrl && !playlist.portalUrl) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="exportPlaylist()"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
<span class="spacer"></span>
|
||||
<button mat-button mat-dialog-close>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="exportPlaylist()"
|
||||
type="submit"
|
||||
[disabled]="
|
||||
!playlistDetails.valid ||
|
||||
playlistDetails.pristine ||
|
||||
isSaving()
|
||||
"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }}
|
||||
{{
|
||||
(isSaving()
|
||||
? 'HOME.STALKER_PORTAL.VALIDATING'
|
||||
: 'HOME.PLAYLISTS.INFO_DIALOG.SAVE'
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
<span class="spacer"></span>
|
||||
<button mat-button mat-dialog-close>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
|
||||
>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</mat-dialog-actions>
|
||||
</fieldset>
|
||||
</form>
|
||||
+195
-3
@@ -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 () => {
|
||||
|
||||
+80
-6
@@ -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<PlaylistInfoComponent>, {
|
||||
optional: true,
|
||||
});
|
||||
public playlistData = inject<Playlist & { id: string }>(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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+48
@@ -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<StalkerPlaylistConnectionResult>;
|
||||
/** Synchronizes in-run Stalker state after the resolved update dispatches. */
|
||||
applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise<void>;
|
||||
}
|
||||
|
||||
export const STALKER_PLAYLIST_CONNECTION_EDITOR =
|
||||
new InjectionToken<StalkerPlaylistConnectionEditor>(
|
||||
'STALKER_PLAYLIST_CONNECTION_EDITOR'
|
||||
);
|
||||
+68
@@ -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 ?? '';
|
||||
}
|
||||
@@ -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<T>(
|
||||
playlist: Playlist,
|
||||
params: Record<string, string | number>,
|
||||
retryOnAuthFailure = true
|
||||
): Promise<T> {
|
||||
const { token, serialNumber } = await this.ensureToken(playlist);
|
||||
|
||||
try {
|
||||
const response = await this.dataService.sendIpcEvent<T>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
token,
|
||||
...(serialNumber ? { serialNumber } : {}),
|
||||
}
|
||||
);
|
||||
if (isStalkerAuthorizationFailure(response)) {
|
||||
return this.handleAuthorizationFailure<T>(
|
||||
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<T>(playlist, params, false);
|
||||
}
|
||||
}
|
||||
// Transport evidence (especially HTTP 401/403) is consumed by
|
||||
// lazy repair. Preserve it exactly after retry exhaustion.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private handleAuthorizationFailure<T>(
|
||||
playlist: Playlist,
|
||||
params: Record<string, string | number>,
|
||||
failedToken: string | null,
|
||||
failure: unknown,
|
||||
retryOnAuthFailure: boolean
|
||||
): Promise<T> {
|
||||
this.tokens.retireFailed(playlist._id, failedToken);
|
||||
if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) {
|
||||
return this.request<T>(playlist, params, false);
|
||||
}
|
||||
|
||||
const failureBody = extractStalkerAuthFailureBody(failure) ?? undefined;
|
||||
throw new StalkerPortalError('auth-failed', failureBody, failureBody);
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<StalkerSessionService['authenticate']>>
|
||||
) => 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
private readonly replacements = new Map<string, Promise<void>>();
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Playlist | null>();
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<PlaylistMeta | null>
|
||||
>();
|
||||
/** Explicit Edit invalidates every repair that started before it. */
|
||||
private readonly editGenerations = new Map<string, number>();
|
||||
|
||||
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<PlaylistMeta | null> {
|
||||
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 ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, string | number>,
|
||||
retryOnAuthFailure = true
|
||||
): Promise<T> {
|
||||
// Get token (will wait if auth is in progress)
|
||||
const { token, serialNumber } = await this.ensureToken(playlist);
|
||||
|
||||
try {
|
||||
const response = await this.dataService.sendIpcEvent<T>(
|
||||
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<T>(
|
||||
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<T>(
|
||||
playlist,
|
||||
params,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.requestClient.request<T>(
|
||||
playlist,
|
||||
params,
|
||||
retryOnAuthFailure
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,7 @@ describe('PlaylistsService', () => {
|
||||
runtime: {
|
||||
get supportsSqlite() {
|
||||
const electron = testWindow.electron as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | 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
|
||||
)
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user