feat(stalker): discover portal connection on edit

This commit is contained in:
4gray committed 2026-08-08 22:06:27 +02:00
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`,
+6
View File
@@ -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;
}
}
+3 -1
View File
@@ -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": "رفضت البوابة الوصول لهذا الجهاز.",
+3 -1
View File
@@ -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": "البوابة رفضات الوصول لهاد الجهاز.",
+3 -1
View File
@@ -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": "Партал адмовіў у доступе для гэтай прылады.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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": "ポータルがこのデバイスのアクセスを拒否しました。",
+3 -1
View File
@@ -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": "포털이 이 기기의 접근을 거부했습니다.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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": "Портал отказал в доступе этому устройству.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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": "门户拒绝了此设备的访问。",
+3 -1
View File
@@ -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": "入口網站拒絕了此裝置的存取。",
+2 -1
View File
@@ -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 }
: {}),
@@ -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">{{
@@ -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,
})
);
});
});
@@ -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
View File
@@ -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';
@@ -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>
@@ -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 () => {
@@ -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);
}
}
@@ -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'
);
@@ -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
);
}
}
+120 -3
View File
@@ -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
)
),
+20 -2
View File
@@ -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;
}