refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 23:24:34 +01:00
1 parent aa739f43e0
commit 7ac2fd66b8
175 files changed
+3118 -1076

No files matched your search

-3
View File
@@ -1,11 +1,8 @@
export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/epg.service';
export * from './lib/epg-progress.service';
export * from './lib/playback-position.service';
export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/settings-store.service';
export * from './lib/sort.service';
export * from './lib/stalker-session.service';
+12 -8
View File
@@ -1,12 +1,13 @@
import { Params } from '@angular/router';
type ElectronProcessWindow = Window & {
process?: {
type?: string;
};
};
export abstract class DataService {
get isElectron(): boolean {
return !!(
window &&
window['process'] &&
(window['process'] as any).type
);
const currentWindow = window as ElectronProcessWindow;
return Boolean(currentWindow.process?.type);
}
get remote() {
return null;
@@ -15,11 +16,14 @@ export abstract class DataService {
return null;
}
abstract getAppVersion(): string;
abstract sendIpcEvent(type: string, payload?: unknown): any;
abstract sendIpcEvent<T = unknown>(
type: string,
payload?: unknown
): T | Promise<T>;
abstract removeAllListeners(type: string): void;
abstract listenOn(
command: string,
callback: (...args: any[]) => void
callback: (...args: unknown[]) => void
): void;
abstract getAppEnvironment(): string;
}
@@ -4,7 +4,10 @@
*/
import { Injectable } from '@angular/core';
import { PlaylistMeta } from 'shared-interfaces';
import {
PlaylistMeta,
XtreamCategory,
} from 'shared-interfaces';
export interface XCategoryFromDb {
id: number;
@@ -37,6 +40,31 @@ export interface XtreamPlaylist {
type: string;
}
type XtreamDatabasePlaylistUpdate = {
name?: string;
username?: string;
password?: string;
serverUrl?: string;
lastUpdated?: string;
};
type XtreamContentStream =
| {
category_id: string | number;
rating?: string | number;
rating_imdb?: string;
last_modified?: string;
added?: string;
stream_icon?: string;
poster?: string;
cover?: string;
name?: string;
title?: string;
series_id?: string | number;
stream_id?: string | number;
}
| Record<string, unknown>;
export interface GlobalSearchResult extends XtreamContent {
playlist_id: string;
playlist_name: string;
@@ -103,7 +131,9 @@ export class DatabaseService {
/**
* Update playlist basic info
*/
async updateXtreamPlaylist(playlist: any): Promise<boolean> {
async updateXtreamPlaylist(
playlist: Pick<XtreamPlaylist, 'id' | 'name'>
): Promise<boolean> {
try {
await window.electron.dbUpdatePlaylist(playlist.id, {
name: playlist.name,
@@ -127,7 +157,7 @@ export class DatabaseService {
updateDate?: number;
}): Promise<boolean> {
try {
const updates: any = {};
const updates: XtreamDatabasePlaylistUpdate = {};
if (playlist.title) updates.name = playlist.title;
if (playlist.username) updates.username = playlist.username;
if (playlist.password) updates.password = playlist.password;
@@ -171,7 +201,7 @@ export class DatabaseService {
*/
async saveXtreamCategories(
playlistId: string,
categories: any[],
categories: XtreamCategory[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): Promise<void> {
@@ -237,7 +267,7 @@ export class DatabaseService {
*/
async saveXtreamContent(
playlistId: string,
streams: any[],
streams: XtreamContentStream[],
type: 'live' | 'movie' | 'series',
onProgress?: (count: number) => void
): Promise<number> {
@@ -1,79 +0,0 @@
import { Injectable, computed, signal } from '@angular/core';
export interface EpgImportProgress {
url: string;
status: 'queued' | 'loading' | 'complete' | 'error';
stats?: { totalChannels: number; totalPrograms: number };
error?: string;
queuePosition?: number;
}
@Injectable({ providedIn: 'root' })
export class EpgProgressService {
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
new Map()
);
private initialized = false;
readonly imports = computed(() => Array.from(this.importsMap().values()));
readonly hasActiveImports = computed(() =>
this.imports().some((item) => item.status === 'loading')
);
readonly activeCount = computed(
() => this.imports().filter((item) => item.status === 'loading').length
);
readonly queuedImports = computed(() =>
this.imports()
.filter((item) => item.status === 'queued')
.sort(
(a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)
)
);
readonly queuedCount = computed(() => this.queuedImports().length);
readonly isVisible = computed(() => this.imports().length > 0);
constructor() {
this.initializeListener();
}
dismiss(url: string): void {
this.removeImport(url);
}
dismissAll(): void {
this.importsMap.set(new Map());
}
private initializeListener(): void {
if (this.initialized) {
return;
}
this.initialized = true;
if (window.electron?.onEpgProgress) {
window.electron.onEpgProgress((data) => {
this.updateProgress(data);
});
}
}
private updateProgress(progress: EpgImportProgress): void {
this.importsMap.update((current) => {
const updated = new Map(current);
updated.set(progress.url, progress);
return updated;
});
if (progress.status === 'complete' || progress.status === 'error') {
setTimeout(() => this.removeImport(progress.url), 5000);
}
}
private removeImport(url: string): void {
this.importsMap.update((current) => {
const updated = new Map(current);
updated.delete(url);
return updated;
});
}
}
-119
View File
@@ -1,119 +0,0 @@
/* import { TestBed } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import { invoke } from '@tauri-apps/api/core';
import { MockModule, MockProviders } from 'ng-mocks';
import { DataService } from './data.service';
import { EpgService } from './epg.service';
jest.mock('@tauri-apps/api/core', () => ({
invoke: jest.fn(),
isTauri: () => true,
}));
describe('EpgService', () => {
let service: EpgService;
let snackBar: MatSnackBar;
let store: MockStore;
let translateService: TranslateService;
let dispatchSpy: jest.SpyInstance;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EpgService,
MockProviders(DataService, TranslateService, MatSnackBar),
provideMockStore(),
],
imports: [MockModule(MatSnackBarModule)],
});
service = TestBed.inject(EpgService);
snackBar = TestBed.inject(MatSnackBar);
store = TestBed.inject(MockStore);
translateService = TestBed.inject(TranslateService);
jest.spyOn(translateService, 'instant').mockImplementation(
(key) => key
);
jest.spyOn(snackBar, 'open');
dispatchSpy = jest.spyOn(store, 'dispatch');
// Suppress console.error during tests
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a service instance', () => {
expect(service).toBeTruthy();
});
describe('fetchEpg', () => {
it('should show success notification on successful fetch', async () => {
(invoke as jest.Mock).mockResolvedValueOnce({});
service.fetchEpg(['http://example.com/epg.xml']);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.FETCH_SUCCESS',
null,
expect.any(Object)
);
});
it('should show error notification on fetch failure', async () => {
(invoke as jest.Mock).mockRejectedValueOnce(new Error('Failed'));
service.fetchEpg(['http://example.com/epg.xml']);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.ERROR',
'CLOSE',
expect.any(Object)
);
});
});
describe('getChannelPrograms', () => {
it('should update programs and set EPG flag to true when programs exist', (done) => {
const mockPrograms = [
{
start: new Date().toISOString(),
stop: new Date().toISOString(),
title: 'Test',
},
];
(invoke as jest.Mock).mockResolvedValueOnce(mockPrograms);
service.getChannelPrograms('test-channel');
service.currentEpgPrograms$.subscribe((programs) => {
expect(programs).toHaveLength(1);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: true })
);
done();
});
});
it('should set EPG flag to false when no programs found', (done) => {
(invoke as jest.Mock).mockResolvedValueOnce([]);
service.getChannelPrograms('test-channel');
service.currentEpgPrograms$.subscribe((programs) => {
expect(programs).toHaveLength(0);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: false })
);
done();
});
});
});
});
*/
-240
View File
@@ -1,240 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs';
import { catchError, map, tap, timeout } from 'rxjs/operators';
import { EpgProgram } from 'shared-interfaces';
interface CachedProgram {
program: EpgProgram | null;
timestamp: number;
}
@Injectable({
providedIn: 'root',
})
export class EpgService {
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private epgAvailable = new BehaviorSubject<boolean>(false);
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
// Cache for channel programs with 60-second TTL
private programCache = new Map<string, CachedProgram>();
private readonly CACHE_TTL = 60000; // 60 seconds
private readonly isDesktop = !!window.electron;
readonly epgAvailable$ = this.epgAvailable.asObservable();
readonly currentEpgPrograms$ = this.currentEpgPrograms.asObservable();
/**
* Fetches EPG from the given URLs
*/
fetchEpg(urls: string[]): void {
if (!this.isDesktop) return;
// Filter out empty URLs and send all URLs at once
const validUrls = urls.filter((url) => url?.trim());
if (validUrls.length === 0) return;
from(window.electron.fetchEpg(validUrls))
.pipe(
tap((result) => {
if (result.success) {
this.epgAvailable.next(true);
} else {
this.epgAvailable.next(false);
this.showErrorSnackbar(result.message);
}
}),
catchError((err) => {
console.error('EPG fetch error:', err);
this.epgAvailable.next(false);
this.showErrorSnackbar();
return of(null);
})
)
.subscribe();
}
/**
* Gets EPG programs for a specific channel
*/
getChannelPrograms(channelId: string): void {
if (!this.isDesktop) return;
console.log('Fetching EPG for channel ID:', channelId);
from(window.electron.getChannelPrograms(channelId))
.pipe(
map((programs: EpgProgram[]) =>
programs.map((program) => ({
...program,
start: new Date(program.start).toISOString(),
stop: new Date(program.stop).toISOString(),
}))
),
catchError((err) => {
console.error('EPG get programs error:', err);
this.showErrorSnackbar();
this.currentEpgPrograms.next([]);
this.epgAvailable.next(false);
return of([]);
})
)
.subscribe((programs) => {
this.epgAvailable.next(programs.length > 0);
this.currentEpgPrograms.next(programs);
});
}
/**
* Shows error snackbar
*/
private showErrorSnackbar(message?: string): void {
const errorMessage = message || this.translate.instant('EPG.ERROR');
this.snackBar.open(errorMessage, this.translate.instant('CLOSE'), {
duration: 3000,
horizontalPosition: 'start',
});
}
/**
* Gets the current EPG program for a specific channel (with caching)
* @param channelId Channel ID (tvg-id or channel name)
* @returns Observable of current program or null
*/
getCurrentProgramForChannel(
channelId: string
): Observable<EpgProgram | null> {
if (!this.isDesktop || !channelId) {
return of(null);
}
// Check cache first
const cached = this.programCache.get(channelId);
const now = Date.now();
if (cached && now - cached.timestamp < this.CACHE_TTL) {
return of(cached.program);
}
// Fetch from backend
return from(window.electron.getChannelPrograms(channelId)).pipe(
map((programs: EpgProgram[]) => {
if (!programs || programs.length === 0) {
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return null;
}
// Normalize date formats to ISO strings for consistency
const transformedPrograms = programs.map((program) => ({
...program,
start: new Date(program.start).toISOString(),
stop: new Date(program.stop).toISOString(),
}));
// Find current program from transformed programs
const currentProgram =
this.findCurrentProgram(transformedPrograms);
// Cache the result
this.programCache.set(channelId, {
program: currentProgram,
timestamp: now,
});
return currentProgram;
}),
catchError((err) => {
console.error('EPG get current program error:', err);
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return of(null);
})
);
}
/**
* Finds the current program from a list of programs
*/
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
const now = new Date();
return (
programs.find((program) => {
const start = new Date(program.start);
const stop = new Date(program.stop);
return start <= now && now <= stop;
}) || null
);
}
/**
* Gets current programs for multiple channels (batch operation)
* @param channelIds Array of channel IDs
* @returns Observable of Map with channelId -> current program
*/
getCurrentProgramsForChannels(
channelIds: string[]
): Observable<Map<string, EpgProgram | null>> {
if (!this.isDesktop) {
return of(new Map());
}
if (!channelIds || channelIds.length === 0) {
return of(new Map());
}
const resultMap = new Map<string, EpgProgram | null>();
const channelsToFetch: string[] = [];
const now = Date.now();
// Check cache for each channel
channelIds.forEach((channelId) => {
const cached = this.programCache.get(channelId);
if (cached && now - cached.timestamp < this.CACHE_TTL) {
resultMap.set(channelId, cached.program);
} else {
channelsToFetch.push(channelId);
}
});
// If all channels were cached, return immediately
if (channelsToFetch.length === 0) {
return of(resultMap);
}
// Fetch uncached channels with timeout and error handling per request
const fetchObservables = channelsToFetch.map((channelId) =>
this.getCurrentProgramForChannel(channelId).pipe(
timeout(5000), // 5 second timeout per request
map((program) => ({ channelId, program })),
catchError(() => of({ channelId, program: null }))
)
);
// Combine all fetches using forkJoin
return forkJoin(fetchObservables).pipe(
map((results) => {
results.forEach((result) => {
resultMap.set(result.channelId, result.program);
});
return resultMap;
})
);
}
/**
* Clears the program cache (useful when EPG is refreshed)
*/
clearCache(): void {
this.programCache.clear();
}
}
+36 -24
View File
@@ -24,12 +24,26 @@ import {
Playlist,
PlaylistMeta,
PlaylistUpdateState,
XtreamItem,
XtreamSerieItem,
StalkerPortalItem,
} from 'shared-interfaces';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
type PortalFavoriteItem = StalkerPortalItem & {
category_id?: string;
raw?: string;
[key: string]: unknown;
};
type PortalRecentlyViewedItem = StalkerPortalItem & {
id?: string | number;
title?: string;
};
type PlaylistRawItem = {
raw?: string;
};
@Injectable({
providedIn: 'root',
})
@@ -255,7 +269,7 @@ export class PlaylistsService {
const playlist = await window.electron.dbGetAppPlaylist(id);
return playlist
? this.createSqliteFallbackPlaylist(playlist as Playlist)
: (undefined as any);
: (undefined as unknown as Playlist);
});
}
@@ -395,7 +409,7 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.favorites) return [];
return item.favorites as Partial<XtreamItem>[];
return item.favorites as PortalFavoriteItem[];
}),
map((favorites) =>
favorites.sort(
@@ -411,7 +425,7 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.favorites) return [];
return (item.favorites as Partial<XtreamItem>[]).filter(
return (item.favorites as PortalFavoriteItem[]).filter(
(itm) =>
itm && itm.stream_type && itm.stream_type === 'live'
);
@@ -419,7 +433,7 @@ export class PlaylistsService {
);
}
addPortalFavorite(portalId: string, item: any) {
addPortalFavorite(portalId: string, item: PortalFavoriteItem) {
if (!portalId) {
throw new Error('Portal ID is required');
}
@@ -449,16 +463,11 @@ export class PlaylistsService {
...portal,
favorites: portal.favorites?.filter((i) => {
const expectedId = String(favoriteId);
const streamId = String(
(i as Partial<XtreamItem>).stream_id ?? ''
);
const seriesId = String(
(i as Partial<XtreamSerieItem>).series_id ?? ''
);
const movieId = String(
(i as Partial<{ movie_id: string }>).movie_id ?? ''
);
const itemId = String((i as any).id ?? '');
const favorite = i as PortalFavoriteItem;
const streamId = String(favorite.stream_id ?? '');
const seriesId = String(favorite.series_id ?? '');
const movieId = String(favorite.movie_id ?? '');
const itemId = String(favorite.id ?? '');
return (
streamId !== expectedId &&
@@ -614,7 +623,7 @@ export class PlaylistsService {
`${playlist.playlist?.header?.raw ?? ''}` +
'\n' +
(playlist.playlist?.items ?? [])
.map((item: any) => item.raw)
.map((item: PlaylistRawItem) => item.raw)
.join('\n')
);
})
@@ -652,7 +661,7 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.recentlyViewed) return [];
return item.recentlyViewed as Partial<XtreamItem>[];
return item.recentlyViewed as PortalRecentlyViewedItem[];
}),
map((items) =>
items.sort(
@@ -666,7 +675,7 @@ export class PlaylistsService {
addPortalRecentlyViewed(
portalId: string,
item: { id: string; title: string }
item: PortalRecentlyViewedItem & { id: string | number; title: string }
) {
if (!portalId) {
throw new Error('Portal ID is required');
@@ -674,9 +683,10 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nowIso = new Date().toISOString();
const recentItems = portal.recentlyViewed ?? [];
const recentItems =
(portal.recentlyViewed as PortalRecentlyViewedItem[]) ?? [];
const existingIndex = recentItems.findIndex(
(i: any) => String(i?.id) === String(item.id)
(recentItem) => String(recentItem?.id) === String(item.id)
);
if (existingIndex >= 0) {
@@ -708,7 +718,7 @@ export class PlaylistsService {
...recentItems,
{
...item,
added_at: (item as any).added_at ?? nowIso,
added_at: item.added_at ?? nowIso,
},
],
};
@@ -736,8 +746,10 @@ export class PlaylistsService {
const nextPlaylist: Playlist = {
...portal,
recentlyViewed: portal.recentlyViewed?.filter((i: any) => {
const itemId = String(i?.id ?? '');
recentlyViewed: (
portal.recentlyViewed as PortalRecentlyViewedItem[]
)?.filter((item) => {
const itemId = String(item?.id ?? '');
const itemNormalized = normalizePortalItemId(itemId);
return (
itemId !== expectedId &&
+25 -15
View File
@@ -3,6 +3,15 @@ import { DataService } from './data.service';
export type PortalStatus = 'active' | 'inactive' | 'expired' | 'unavailable';
interface XtreamPortalStatusResponse {
payload?: {
user_info?: {
status?: string;
exp_date?: string;
};
};
}
@Injectable({
providedIn: 'root',
})
@@ -28,26 +37,27 @@ export class PortalStatusService {
normalizedUrl = serverUrl;
}
let response = await this.dataService.sendIpcEvent(
'XTREAM_REQUEST',
{
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
}
);
response = response?.payload;
const response =
await this.dataService.sendIpcEvent<XtreamPortalStatusResponse>(
'XTREAM_REQUEST',
{
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
}
);
const payload = response?.payload;
if (!response?.user_info?.status) {
if (!payload?.user_info?.status) {
return 'unavailable';
}
if (response.user_info.status === 'Active') {
if (payload.user_info.status === 'Active') {
const expDate = new Date(
parseInt(response.user_info.exp_date) * 1000
parseInt(payload.user_info.exp_date) * 1000
);
return expDate < new Date() ? 'expired' : 'active';
} else {
@@ -1,620 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from 'shared-interfaces';
import { DataService } from './data.service';
/**
* SHA1 hash using native Web Crypto API
* Produces correct 40-character hex hash matching real Stalker clients
*/
async function sha1(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates SHA1 prehash from MAC address
* This must match what real Stalker clients send
*/
async function generatePrehash(macAddress: string): Promise<string> {
// Use MAC address with colons, uppercase - this is what most clients use
const str = macAddress.toUpperCase();
return (await sha1(str)).toUpperCase();
}
/**
* Generates a random string for metrics
*/
function generateRandom(): string {
const chars = 'abcdef0123456789';
let result = '';
for (let i = 0; i < 40; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Deterministic serial number - 13 hex characters
* Constant value for consistency across all sessions
*/
export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF';
const STALKER_WATCHDOG_INTERVAL_MS = 25_000;
export interface StalkerHandshakeResponse {
js: {
token: string;
not_valid?: number;
random?: string;
};
}
export interface StalkerProfileResponse {
js: {
id?: string;
name?: string;
mac?: string;
status?: number;
msg?: string;
block_msg?: string;
account_info?: {
login?: string;
expire_date?: number;
tariff_plan_name?: string;
status?: number;
};
};
}
/**
* Service to manage Stalker portal session tokens.
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
* Persists tokens during session and handles re-authentication on auth failures.
*/
@Injectable({
providedIn: 'root',
})
export class StalkerSessionService {
private dataService = inject(DataService);
// In-memory token cache for current session (keyed by playlist ID)
private tokenCache = new Map<string, string>();
// Pending authentication promises to prevent race conditions
// When multiple requests need a token simultaneously, they all wait for the same auth
private pendingAuth = new Map<
string,
Promise<{ token: string; serialNumber?: string }>
>();
private watchdogIntervals = new Map<string, ReturnType<typeof setInterval>>();
private watchdogPlaylists = new Map<string, Playlist>();
private watchdogInFlight = new Set<string>();
private activeWatchdogPlaylistId: string | null = null;
/**
* Generates a consistent 64-character device ID from MAC address
*/
private generateDeviceId(macAddress: string): string {
// Generate consistent device ID from MAC
const str = macAddress.toUpperCase().replace(/:/g, '');
const chars = '0123456789ABCDEF';
let result = '';
let seed = 0;
for (let i = 0; i < str.length; i++) {
seed = (seed * 31 + str.charCodeAt(i)) >>> 0;
}
for (let i = 0; i < 64; i++) {
seed = (seed * 1103515245 + 12345) >>> 0;
result += chars[seed % 16];
}
return result;
}
/**
* Checks if a URL is a full stalker portal URL (requires handshake)
* Full stalker portal URLs contain /stalker_portal/ in the path
*/
isFullStalkerPortal(url: string): boolean {
return (
url.includes('/stalker_portal/') || url.includes('/server/load.php')
);
}
/**
* Gets the cached token for a playlist, or null if not cached
*/
getCachedToken(playlistId: string): string | null {
return this.tokenCache.get(playlistId) || null;
}
/**
* Sets a token in the cache
*/
setCachedToken(playlistId: string, token: string): void {
this.tokenCache.set(playlistId, token);
}
/**
* Clears the cached token for a playlist (e.g., on auth failure)
*/
clearCachedToken(playlistId: string): void {
this.tokenCache.delete(playlistId);
}
/**
* Sets which playlist should receive periodic watchdog pings.
* This keeps some Ministra/Stalker sessions alive for live playback.
*/
setActiveWatchdogPlaylist(playlist?: Playlist | null): void {
const nextPlaylistId = playlist?._id ?? null;
if (
this.activeWatchdogPlaylistId &&
this.activeWatchdogPlaylistId !== nextPlaylistId
) {
this.stopWatchdog(this.activeWatchdogPlaylistId);
}
this.activeWatchdogPlaylistId = nextPlaylistId;
if (
!playlist ||
!playlist.isFullStalkerPortal ||
!playlist.portalUrl ||
!playlist.macAddress
) {
if (nextPlaylistId) {
this.stopWatchdog(nextPlaylistId);
}
return;
}
this.startWatchdog(playlist);
}
private startWatchdog(playlist: Playlist): void {
const playlistId = playlist._id;
this.watchdogPlaylists.set(playlistId, playlist);
if (this.watchdogIntervals.has(playlistId)) {
return;
}
void this.sendWatchdogPing(playlistId, '1');
const intervalId = setInterval(() => {
void this.sendWatchdogPing(playlistId, '0');
}, STALKER_WATCHDOG_INTERVAL_MS);
this.watchdogIntervals.set(playlistId, intervalId);
}
private stopWatchdog(playlistId: string): void {
const intervalId = this.watchdogIntervals.get(playlistId);
if (intervalId) {
clearInterval(intervalId);
this.watchdogIntervals.delete(playlistId);
}
this.watchdogPlaylists.delete(playlistId);
this.watchdogInFlight.delete(playlistId);
}
private async sendWatchdogPing(
playlistId: string,
init: '0' | '1'
): Promise<void> {
if (this.watchdogInFlight.has(playlistId)) {
return;
}
const playlist = this.watchdogPlaylists.get(playlistId);
if (
!playlist ||
!playlist.portalUrl ||
!playlist.macAddress ||
!playlist.isFullStalkerPortal
) {
this.stopWatchdog(playlistId);
return;
}
this.watchdogInFlight.add(playlistId);
try {
await this.makeAuthenticatedRequest(
playlist,
{
type: 'watchdog',
action: 'get_events',
event_active_id: '0',
cur_play_type: '0',
init,
JsHttpRequest: '1-xml',
},
false
);
} catch (error) {
// Keep failures non-fatal; next interval can recover after token refresh.
console.warn('[StalkerSession] Watchdog ping failed:', error);
} finally {
this.watchdogInFlight.delete(playlistId);
}
}
/**
* Performs handshake to get a session token for a full stalker portal
* Returns both the token and the random value for use in subsequent requests
*/
async performHandshake(
portalUrl: string,
macAddress: string
): Promise<{ token: string; random: string }> {
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'handshake',
token: '',
prehash,
JsHttpRequest: '1-xml',
};
try {
const response: StalkerHandshakeResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
});
if (response?.js?.token) {
return {
token: response.js.token,
random: response.js.random || generateRandom(),
};
}
console.error('[StalkerSession] No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
console.error('[StalkerSession] Handshake error:', error);
throw error;
}
}
/**
* Gets account profile information to validate the portal and check subscription
* Based on working implementation from stalker-to-m3u repo
*/
async getProfile(
portalUrl: string,
macAddress: string,
token: string,
serialNumber: string,
handshakeRandom: string,
providedDeviceId1?: string,
providedDeviceId2?: string,
providedSignature1?: string,
providedSignature2?: string
): Promise<StalkerProfileResponse> {
// Use provided device IDs or generate from MAC (64 hex chars each)
const deviceId1 =
providedDeviceId1?.trim() || this.generateDeviceId(macAddress);
const deviceId2 = providedDeviceId2?.trim() || deviceId1;
// Use provided signatures or empty string (some portals don't require them)
const signature1 = providedSignature1?.trim() || '';
const signature2 = providedSignature2?.trim() || '';
// Build metrics JSON matching working app
const metrics = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
sn: serialNumber,
};
// Generate prehash for get_profile (same as handshake)
const prehash = await generatePrehash(macAddress);
// Profile request matching working StalkerTV app
// auth_second_step=1, includes metrics, prehash, and device_id params
const params: Record<string, string> = {
type: 'stb',
action: 'get_profile',
hd: '1',
not_valid_token: '0',
video_out: 'hdmi',
auth_second_step: '1',
num_banks: '2',
metrics: JSON.stringify(metrics),
sn: serialNumber,
device_id: deviceId1,
device_id2: deviceId2,
signature: signature1,
...(signature2 ? { signature2: signature2 } : {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
};
try {
const response: StalkerProfileResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
token,
serialNumber,
});
return response;
} catch (error) {
console.error('[StalkerSession] Get profile error:', error);
throw error;
}
}
/**
* Performs do_auth to authenticate the session after handshake
*/
async doAuth(
portalUrl: string,
macAddress: string,
token: string
): Promise<boolean> {
const params: Record<string, string> = {
type: 'stb',
action: 'do_auth',
login: '',
password: '',
device_id: '',
device_id2: '',
JsHttpRequest: '1-xml',
};
try {
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
}
);
// do_auth returns { js: true } on success
if (response?.js === true) {
return true;
}
return false;
} catch (error) {
console.error('[StalkerSession] do_auth error:', error);
throw error;
}
}
/**
* Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces)
* Returns the token and account info if successful
*/
async authenticate(
portalUrl: string,
macAddress: string,
serialNumber: string,
deviceId1?: string,
deviceId2?: string,
signature1?: string,
signature2?: string
): Promise<{
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
}> {
// Step 1: Handshake to get token and random
const { token, random } = await this.performHandshake(
portalUrl,
macAddress
);
// Step 2: Get profile to activate token and get account info
// The random from handshake must be used in auth_second_step
try {
const profileResponse = await this.getProfile(
portalUrl,
macAddress,
token,
serialNumber,
random,
deviceId1,
deviceId2,
signature1,
signature2
);
// Check for profile-level errors
if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) {
const errorMsg =
profileResponse.js.msg ||
profileResponse.js.block_msg ||
'Unknown profile error';
console.error('[StalkerSession] Profile error:', errorMsg);
throw new Error(`Profile error: ${errorMsg}`);
}
return {
token,
accountInfo: profileResponse?.js?.account_info,
};
} catch (error) {
// Profile fetch failed - this is a real error, propagate it
console.error('[StalkerSession] Profile fetch failed:', error);
throw error;
}
}
/**
* Ensures a valid token exists for a playlist, performing full auth if needed
* IMPORTANT: Based on working traces, each session needs handshake + get_profile
* Returns the token to use for requests, and the serial number to store
*/
async ensureToken(
playlist: Playlist
): Promise<{ token: string | null; serialNumber?: string }> {
// If not a full stalker portal, no token needed
if (!playlist.isFullStalkerPortal) {
return { token: null };
}
// Check in-memory cache first (valid for current session only)
const cachedToken = this.getCachedToken(playlist._id);
if (cachedToken) {
return { token: cachedToken };
}
// Check if there's already a pending authentication for this playlist
// This prevents race conditions when multiple resources request a token simultaneously
const pendingPromise = this.pendingAuth.get(playlist._id);
if (pendingPromise) {
console.log(
'[StalkerSession] Waiting for pending authentication...'
);
return pendingPromise;
}
// No cached token - need to do full authentication (handshake + get_profile)
// Don't trust stored tokens as they may be from a different session
if (!playlist.portalUrl || !playlist.macAddress) {
console.error('[StalkerSession] Missing portal URL or MAC address');
throw new Error('Portal URL and MAC address are required');
}
// Get or generate serial number - must be consistent for the MAC
let serialNumber = playlist.stalkerSerialNumber;
if (!serialNumber) {
serialNumber = STALKER_SERIAL_NUMBER;
}
// Create the authentication promise and store it to prevent concurrent auth attempts
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
try {
const { token } = await this.authenticate(
playlist.portalUrl,
playlist.macAddress,
serialNumber
);
this.setCachedToken(playlist._id, token);
return { token, serialNumber };
} finally {
// Clean up pending promise regardless of success/failure
this.pendingAuth.delete(playlist._id);
}
})();
// Store the pending promise so other concurrent requests can wait on it
this.pendingAuth.set(playlist._id, authPromise);
return authPromise;
}
/**
* Checks if a response or error indicates an authorization failure
*/
private isAuthorizationError(responseOrError: unknown): boolean {
if (!responseOrError) return false;
const response = responseOrError as Record<string, unknown>;
// Convert response to string for pattern matching
const responseStr = JSON.stringify(responseOrError).toLowerCase();
// Check for "Authorization failed. XX" pattern (like "Authorization failed. 75")
if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) {
return true;
}
// Check for common auth failure indicators
const jsData = response?.['js'] as Record<string, unknown>;
const errorMessage =
(response?.['message'] as string)?.toLowerCase?.() ||
jsData?.['error']?.toString?.().toLowerCase?.() ||
jsData?.['msg']?.toString?.().toLowerCase?.() ||
'';
return (
errorMessage.includes('authorization') ||
errorMessage.includes('unauthorized') ||
errorMessage.includes('auth failed') ||
errorMessage.includes('invalid token') ||
response?.['status'] === 401 ||
jsData?.['error'] === 'Authorization failed'
);
}
/**
* Wrapper for making stalker requests with automatic token handling and retry on auth failure
* This should be used by all stalker API calls to ensure proper auth handling
*/
async makeAuthenticatedRequest<T>(
playlist: Playlist,
params: Record<string, string | number>,
retryOnAuthFailure = true
): Promise<T> {
// Get token (will wait if auth is in progress)
const { token } = await this.ensureToken(playlist);
const serialNumber = playlist.stalkerSerialNumber;
try {
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
}
);
// Check for authorization failure in response
if (this.isAuthorizationError(response)) {
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
// Clear cached token to force re-authentication
this.clearCachedToken(playlist._id);
// Retry once with fresh authentication
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
throw new Error('Authorization failed after retry');
}
return response;
} catch (error) {
// Check if error indicates auth failure
if (
this.isAuthorizationError(error) &&
retryOnAuthFailure &&
playlist.isFullStalkerPortal
) {
// Clear cached token and retry with new handshake
this.clearCachedToken(playlist._id);
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
throw error;
}
}
}