mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
aa739f43e0
commit
7ac2fd66b8
175 files changed
+3118
-1076
No files matched your search
@@ -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';
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user