Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EpgQueueService } from './epg-queue.service';
|
||||
import { XtreamApiService } from './xtream-api.service';
|
||||
|
||||
describe('EpgQueueService', () => {
|
||||
let service: EpgQueueService;
|
||||
let xtreamApi: { getShortEpg: jest.Mock };
|
||||
|
||||
const credentials = {
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
xtreamApi = {
|
||||
getShortEpg: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EpgQueueService,
|
||||
{
|
||||
provide: XtreamApiService,
|
||||
useValue: xtreamApi,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(EpgQueueService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('caches empty EPG responses and does not immediately refetch them', async () => {
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
|
||||
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
|
||||
credentials,
|
||||
101
|
||||
);
|
||||
|
||||
expect(service.getCached(101)).toEqual([]);
|
||||
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
|
||||
credentials,
|
||||
101,
|
||||
3,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
expect(
|
||||
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
|
||||
101
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('applies a cooldown after EPG request failures', async () => {
|
||||
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
|
||||
|
||||
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
|
||||
credentials,
|
||||
202
|
||||
);
|
||||
|
||||
expect(
|
||||
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
|
||||
202
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
jest.advanceTimersByTime(60_001);
|
||||
|
||||
expect(
|
||||
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
|
||||
202
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { inject, Injectable, OnDestroy } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
interface CacheEntry {
|
||||
data: EpgItem[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled EPG request queue with concurrency control, inter-request
|
||||
* delay, and in-memory caching. Prevents Xtream providers from
|
||||
* rate-limiting / banning the client when scrolling through large
|
||||
* channel lists.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EpgQueueService implements OnDestroy {
|
||||
private readonly apiService = inject(XtreamApiService);
|
||||
private readonly logger = createLogger('EpgQueueService');
|
||||
private readonly previewLimit = 3;
|
||||
|
||||
private readonly cache = new Map<number, CacheEntry>();
|
||||
private queue: number[] = [];
|
||||
private readonly inFlight = new Set<number>();
|
||||
private visibleSet = new Set<number>();
|
||||
private processing = false;
|
||||
private readonly failureTimestamps = new Map<number, number>();
|
||||
|
||||
private readonly maxConcurrency = 2;
|
||||
private readonly delayMs = 200;
|
||||
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly failureCooldownMs = 60 * 1000;
|
||||
|
||||
/** Emits EPG results as they arrive. */
|
||||
readonly epgResult$ = new Subject<{ streamId: number; items: EpgItem[] }>();
|
||||
|
||||
/** Return cached EPG items if still valid, otherwise null. */
|
||||
getCached(streamId: number): EpgItem[] | null {
|
||||
const entry = this.cache.get(streamId);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.timestamp > this.cacheTtlMs) {
|
||||
this.cache.delete(streamId);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
private isFailureCoolingDown(streamId: number): boolean {
|
||||
const timestamp = this.failureTimestamps.get(streamId);
|
||||
if (timestamp == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.now() - timestamp > this.failureCooldownMs) {
|
||||
this.failureTimestamps.delete(streamId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private shouldFetch(streamId: number): boolean {
|
||||
return (
|
||||
this.getCached(streamId) === null &&
|
||||
!this.isFailureCoolingDown(streamId) &&
|
||||
!this.inFlight.has(streamId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue stream IDs for EPG fetching.
|
||||
*
|
||||
* - IDs already cached or in-flight are skipped.
|
||||
* - The internal queue is replaced so that stale IDs (no longer in
|
||||
* `visibleIds`) are dropped on next dequeue.
|
||||
*/
|
||||
enqueue(
|
||||
streamIds: number[],
|
||||
visibleIds: Set<number>,
|
||||
credentials: XtreamCredentials
|
||||
): void {
|
||||
this.visibleSet = visibleIds;
|
||||
|
||||
const toFetch = streamIds.filter(
|
||||
(id) => this.shouldFetch(id)
|
||||
);
|
||||
|
||||
// Replace the queue – any previously queued but now-invisible IDs
|
||||
// will be skipped during processing via the visibleSet check.
|
||||
this.queue = toFetch;
|
||||
|
||||
if (!this.processing) {
|
||||
this.processQueue(credentials);
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue(credentials: XtreamCredentials): Promise<void> {
|
||||
this.processing = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
// Wait until a concurrency slot is available
|
||||
if (this.inFlight.size >= this.maxConcurrency) {
|
||||
await this.delay(this.delayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const streamId = this.queue.shift();
|
||||
if (streamId == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop stale entries that are no longer visible
|
||||
if (!this.visibleSet.has(streamId)) continue;
|
||||
|
||||
// Skip if it got cached or cooled down while queued (e.g. duplicate/failure)
|
||||
if (!this.shouldFetch(streamId)) continue;
|
||||
|
||||
this.inFlight.add(streamId);
|
||||
this.fetchEpg(credentials, streamId);
|
||||
|
||||
// Space out requests
|
||||
await this.delay(this.delayMs);
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
private async fetchEpg(
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const items = await this.apiService.getShortEpg(
|
||||
credentials,
|
||||
streamId,
|
||||
this.previewLimit,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
this.cache.set(streamId, {
|
||||
data: items,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.failureTimestamps.delete(streamId);
|
||||
this.epgResult$.next({ streamId, items });
|
||||
} catch (error) {
|
||||
this.failureTimestamps.set(streamId, Date.now());
|
||||
this.logger.error(
|
||||
`Failed to load EPG for stream ${streamId}`,
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
this.inFlight.delete(streamId);
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.epgResult$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface FavoriteItem {
|
||||
content_id: number;
|
||||
playlist_id: string;
|
||||
type: 'live' | 'movie' | 'vod' | 'series';
|
||||
title: string;
|
||||
stream_icon?: string;
|
||||
poster_url?: string;
|
||||
added_at?: string;
|
||||
category_id: number;
|
||||
xtream_id: number;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { from, map, Observable } from 'rxjs';
|
||||
import { DatabaseService } from 'services';
|
||||
import { FavoriteItem } from './favorite-item.interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class FavoritesService {
|
||||
private dbService = inject(DatabaseService);
|
||||
|
||||
async addToFavorites(item: {
|
||||
content_id: number;
|
||||
playlist_id: string;
|
||||
backdrop_url?: string;
|
||||
}): Promise<void> {
|
||||
await this.dbService.addToFavorites(
|
||||
item.content_id,
|
||||
item.playlist_id,
|
||||
item.backdrop_url
|
||||
);
|
||||
}
|
||||
|
||||
async removeFromFavorites(
|
||||
contentId: number,
|
||||
playlistId: string
|
||||
): Promise<void> {
|
||||
await this.dbService.removeFromFavorites(contentId, playlistId);
|
||||
}
|
||||
|
||||
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
|
||||
return await this.dbService.isFavorite(contentId, playlistId);
|
||||
}
|
||||
|
||||
getFavorites(playlistId: string): Observable<FavoriteItem[]> {
|
||||
return from(this.dbService.getFavorites(playlistId)).pipe(
|
||||
map((items) =>
|
||||
items.map((item) => ({
|
||||
content_id: item.id,
|
||||
playlist_id: playlistId,
|
||||
type: item.type as FavoriteItem['type'],
|
||||
title: item.title,
|
||||
poster_url: item.poster_url,
|
||||
added_at: item.added_at,
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './epg-queue.service';
|
||||
export * from './favorite-item.interface';
|
||||
export * from './favorites.service';
|
||||
export * from './xtream-api.service';
|
||||
export * from './xtream-url.service';
|
||||
@@ -0,0 +1,251 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DataService } from 'services';
|
||||
import { XTREAM_REQUEST } from 'shared-interfaces';
|
||||
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
||||
|
||||
describe('XtreamApiService', () => {
|
||||
let service: XtreamApiService;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, [string, unknown]>;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: 'http://demo.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
XtreamApiService,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(XtreamApiService);
|
||||
});
|
||||
|
||||
it('falls back to the legacy full-epg action and normalizes the response', async () => {
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
async (_type: string, payload: unknown) => {
|
||||
const action = (
|
||||
payload as {
|
||||
params: { action: string };
|
||||
}
|
||||
).params.action;
|
||||
|
||||
if (action === 'get_simple_data_table') {
|
||||
return { payload: { epg_listings: [] } };
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
epg_listings: [
|
||||
{
|
||||
id: 'later',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Later Show').toString(
|
||||
'base64'
|
||||
),
|
||||
description: Buffer.from(
|
||||
'Later description'
|
||||
).toString('base64'),
|
||||
start: '2026-04-04 11:00:00',
|
||||
end: '2026-04-04 11:30:00',
|
||||
start_timestamp: '1775300400',
|
||||
stop_timestamp: '1775302200',
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString(
|
||||
'base64'
|
||||
),
|
||||
description: Buffer.from(
|
||||
'Current description'
|
||||
).toString('base64'),
|
||||
start: '2026-04-04 10:30:00',
|
||||
end: '2026-04-04 11:00:00',
|
||||
start_timestamp: '1775298600',
|
||||
stop_timestamp: '1775300400',
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const items = await service.getFullEpg(credentials, 101);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
XTREAM_REQUEST,
|
||||
expect.objectContaining({
|
||||
url: 'http://demo.example',
|
||||
params: expect.objectContaining({
|
||||
action: 'get_simple_data_table',
|
||||
stream_id: '101',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
XTREAM_REQUEST,
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: 'get_simple_date_table',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map((item) => item.title)).toEqual([
|
||||
'Current Show',
|
||||
'Later Show',
|
||||
]);
|
||||
expect(items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
description: 'Current description',
|
||||
start: '2026-04-04T10:30:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
start_timestamp: '1775298600',
|
||||
stop_timestamp: '1775300400',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes short epg items from unix timestamps for display', async () => {
|
||||
const startTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T05:30:00.000Z') / 1000
|
||||
);
|
||||
const stopTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T06:00:00.000Z') / 1000
|
||||
);
|
||||
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
epg_listings: [
|
||||
{
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString('base64'),
|
||||
description: Buffer.from(
|
||||
'Current description'
|
||||
).toString('base64'),
|
||||
start: '2026-04-05 03:00:00',
|
||||
end: '2026-04-05 03:30:00',
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const items = await service.getShortEpg(credentials, 101, 4);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'Current Show',
|
||||
description: 'Current description',
|
||||
start: '2026-04-05T05:30:00.000Z',
|
||||
stop: '2026-04-05T06:00:00.000Z',
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to parsed short epg date strings when unix timestamps are absent', async () => {
|
||||
const rawStart = '2026-04-05 03:00:00';
|
||||
const rawStop = '2026-04-05 03:30:00';
|
||||
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
epg_listings: [
|
||||
{
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString('base64'),
|
||||
description: Buffer.from(
|
||||
'Current description'
|
||||
).toString('base64'),
|
||||
start: rawStart,
|
||||
end: rawStop,
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const items = await service.getShortEpg(credentials, 101, 4);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
start: new Date(rawStart.replace(' ', 'T')).toISOString(),
|
||||
stop: new Date(rawStop.replace(' ', 'T')).toISOString(),
|
||||
start_timestamp: '',
|
||||
stop_timestamp: '',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes short and full epg items consistently for the same timestamps', async () => {
|
||||
const startTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T05:30:00.000Z') / 1000
|
||||
);
|
||||
const stopTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T06:00:00.000Z') / 1000
|
||||
);
|
||||
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
async (_type: string, payload: unknown) => {
|
||||
const action = (
|
||||
payload as {
|
||||
params: { action: string };
|
||||
}
|
||||
).params.action;
|
||||
|
||||
const listing = {
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString('base64'),
|
||||
description: Buffer.from('Current description').toString(
|
||||
'base64'
|
||||
),
|
||||
start: '2026-04-05 03:00:00',
|
||||
end: '2026-04-05 03:30:00',
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
channel_id: 'channel-101.mock',
|
||||
};
|
||||
|
||||
if (action === 'get_short_epg') {
|
||||
return { payload: { epg_listings: [listing] } };
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
epg_listings: [listing],
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const shortItems = await service.getShortEpg(credentials, 101, 4);
|
||||
const fullItems = await service.getFullEpg(credentials, 101);
|
||||
|
||||
expect(shortItems[0].start).toBe(fullItems[0].start);
|
||||
expect(shortItems[0].stop).toBe(fullItems[0].stop);
|
||||
expect(shortItems[0].start_timestamp).toBe(fullItems[0].start_timestamp);
|
||||
expect(shortItems[0].stop_timestamp).toBe(fullItems[0].stop_timestamp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
EpgItem,
|
||||
XtreamCategory,
|
||||
XtreamCodeActions,
|
||||
XtreamLiveStream,
|
||||
XtreamSerieDetails,
|
||||
XtreamSerieItem,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
XTREAM_REQUEST,
|
||||
} from 'shared-interfaces';
|
||||
import { XtreamAccountInfo } from '../account-info/account-info.interface';
|
||||
|
||||
/**
|
||||
* Xtream API credentials
|
||||
*/
|
||||
export interface XtreamCredentials {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category type mapping for API calls
|
||||
*/
|
||||
export type CategoryType = 'live' | 'vod' | 'series';
|
||||
|
||||
/**
|
||||
* Stream type mapping for API calls
|
||||
*/
|
||||
export type StreamType = 'live' | 'movie' | 'series';
|
||||
|
||||
export interface XtreamRequestOptions {
|
||||
requestId?: string;
|
||||
sessionId?: string;
|
||||
suppressErrorLog?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw EPG listing from API (before decoding)
|
||||
*/
|
||||
interface RawEpgListing {
|
||||
id?: string;
|
||||
epg_id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
stop?: string;
|
||||
start_timestamp?: string;
|
||||
stop_timestamp?: string;
|
||||
channel_id?: string;
|
||||
lang?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* EPG API response
|
||||
*/
|
||||
interface EpgResponse {
|
||||
epg_listings?: RawEpgListing[] | Record<string, RawEpgListing>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for all Xtream Codes API interactions.
|
||||
* Extracts API communication logic from the XtreamStore.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class XtreamApiService {
|
||||
private readonly dataService = inject(DataService);
|
||||
|
||||
async cancelSession(sessionId: string): Promise<boolean> {
|
||||
if (!sessionId || typeof window.electron?.xtreamCancelSession !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.xtreamCancelSession(sessionId);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel Xtream session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account/portal status
|
||||
*/
|
||||
async getAccountInfo(
|
||||
credentials: XtreamCredentials,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamAccountInfo> {
|
||||
return this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
action: XtreamCodeActions.GetAccountInfo,
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories by type
|
||||
*/
|
||||
async getCategories(
|
||||
credentials: XtreamCredentials,
|
||||
type: CategoryType,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamCategory[]> {
|
||||
const actionMap: Record<CategoryType, XtreamCodeActions> = {
|
||||
live: XtreamCodeActions.GetLiveCategories,
|
||||
vod: XtreamCodeActions.GetVodCategories,
|
||||
series: XtreamCodeActions.GetSeriesCategories,
|
||||
};
|
||||
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: actionMap[type],
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return Array.isArray(response) ? response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get live streams
|
||||
*/
|
||||
async getLiveStreams(
|
||||
credentials: XtreamCredentials,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamLiveStream[]> {
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetLiveStreams,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return Array.isArray(response) ? response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VOD streams
|
||||
*/
|
||||
async getVodStreams(
|
||||
credentials: XtreamCredentials,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamVodStream[]> {
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetVodStreams,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return Array.isArray(response) ? response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get series items
|
||||
*/
|
||||
async getSeriesStreams(
|
||||
credentials: XtreamCredentials,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamSerieItem[]> {
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSeries,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return Array.isArray(response) ? response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streams by type (unified method)
|
||||
*/
|
||||
async getStreams(
|
||||
credentials: XtreamCredentials,
|
||||
type: StreamType,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
|
||||
switch (type) {
|
||||
case 'live':
|
||||
return this.getLiveStreams(credentials, options);
|
||||
case 'movie':
|
||||
return this.getVodStreams(credentials, options);
|
||||
case 'series':
|
||||
return this.getSeriesStreams(credentials, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VOD details
|
||||
*/
|
||||
async getVodInfo(
|
||||
credentials: XtreamCredentials,
|
||||
vodId: string | number,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamVodDetails> {
|
||||
return this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetVodInfo,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
vod_id: vodId,
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get series details
|
||||
*/
|
||||
async getSeriesInfo(
|
||||
credentials: XtreamCredentials,
|
||||
seriesId: string | number,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<XtreamSerieDetails> {
|
||||
return this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSeriesInfo,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
series_id: seriesId,
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short EPG for a stream
|
||||
* Returns decoded EPG items (title and description are base64 decoded)
|
||||
*/
|
||||
async getShortEpg(
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
limit = 10,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<EpgItem[]> {
|
||||
const response: EpgResponse = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetShortEpg,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
stream_id: streamId,
|
||||
limit,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return this.normalizeShortEpgItems(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full EPG schedule for a stream.
|
||||
* Uses the documented endpoint with a fallback for older typoed panels.
|
||||
*/
|
||||
async getFullEpg(
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<EpgItem[]> {
|
||||
try {
|
||||
const response: EpgResponse = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSimpleDataTable,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
stream_id: streamId,
|
||||
},
|
||||
options
|
||||
);
|
||||
const items = this.normalizeFullEpgItems(response);
|
||||
if (items.length > 0) {
|
||||
return items;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the legacy typo endpoint below.
|
||||
}
|
||||
|
||||
const fallbackResponse: EpgResponse = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSimpleDateTable,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
stream_id: streamId,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return this.normalizeFullEpgItems(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 unicode string (used for EPG data)
|
||||
*/
|
||||
private decodeBase64Unicode(str: string): string {
|
||||
try {
|
||||
return decodeURIComponent(
|
||||
Array.prototype.map
|
||||
.call(atob(str), (c: string) => {
|
||||
return (
|
||||
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
||||
);
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
private getEpgListings(response: EpgResponse | null | undefined): RawEpgListing[] {
|
||||
const listings = response?.epg_listings;
|
||||
if (!listings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(listings)) {
|
||||
return listings;
|
||||
}
|
||||
|
||||
return Object.values(listings);
|
||||
}
|
||||
|
||||
private normalizeShortEpgItems(response: EpgResponse): EpgItem[] {
|
||||
return this.getEpgListings(response)
|
||||
.map((item, index) => {
|
||||
const startTimestamp = this.parseUnixTimestamp(
|
||||
item.start_timestamp
|
||||
);
|
||||
const stopTimestamp = this.parseUnixTimestamp(
|
||||
item.stop_timestamp
|
||||
);
|
||||
const normalizedStart =
|
||||
this.toIsoString(startTimestamp) ??
|
||||
this.normalizeDateString(item.start);
|
||||
const normalizedStop =
|
||||
this.toIsoString(stopTimestamp) ??
|
||||
this.normalizeDateString(item.stop ?? item.end);
|
||||
|
||||
return {
|
||||
id: String(item.id ?? index),
|
||||
epg_id: String(item.epg_id ?? ''),
|
||||
title: this.decodeBase64Unicode(
|
||||
String(item.title ?? '')
|
||||
).trim(),
|
||||
description: this.decodeBase64Unicode(
|
||||
String(item.description ?? '')
|
||||
).trim(),
|
||||
lang: String(item.lang ?? ''),
|
||||
start: normalizedStart,
|
||||
end: normalizedStop,
|
||||
stop: normalizedStop,
|
||||
channel_id: String(item.channel_id ?? ''),
|
||||
start_timestamp: String(startTimestamp ?? ''),
|
||||
stop_timestamp: String(stopTimestamp ?? ''),
|
||||
} satisfies EpgItem;
|
||||
})
|
||||
.filter((item) => Boolean(item.start) && Boolean(item.stop))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
this.getEpgItemTimestampMs(left.start, left.start_timestamp) -
|
||||
this.getEpgItemTimestampMs(
|
||||
right.start,
|
||||
right.start_timestamp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeFullEpgItems(response: EpgResponse): EpgItem[] {
|
||||
return this.getEpgListings(response)
|
||||
.map((item, index) => {
|
||||
const startTimestamp = this.parseUnixTimestamp(
|
||||
item.start_timestamp
|
||||
);
|
||||
const stopTimestamp = this.parseUnixTimestamp(
|
||||
item.stop_timestamp
|
||||
);
|
||||
const normalizedStart =
|
||||
this.toIsoString(startTimestamp) ??
|
||||
this.normalizeDateString(item.start);
|
||||
const normalizedStop =
|
||||
this.toIsoString(stopTimestamp) ??
|
||||
this.normalizeDateString(item.stop ?? item.end);
|
||||
|
||||
return {
|
||||
id: String(item.id ?? index),
|
||||
epg_id: String(item.epg_id ?? item.channel_id ?? ''),
|
||||
title: this.decodeBase64Unicode(
|
||||
String(item.title ?? '')
|
||||
).trim(),
|
||||
description: this.decodeBase64Unicode(
|
||||
String(item.description ?? '')
|
||||
).trim(),
|
||||
lang: String(item.lang ?? ''),
|
||||
start: normalizedStart,
|
||||
end: normalizedStop,
|
||||
stop: normalizedStop,
|
||||
channel_id: String(item.channel_id ?? ''),
|
||||
start_timestamp: String(startTimestamp ?? ''),
|
||||
stop_timestamp: String(stopTimestamp ?? ''),
|
||||
} satisfies EpgItem;
|
||||
})
|
||||
.filter((item) => Boolean(item.start) && Boolean(item.stop))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
this.getEpgItemTimestampMs(left.start, left.start_timestamp) -
|
||||
this.getEpgItemTimestampMs(
|
||||
right.start,
|
||||
right.start_timestamp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private parseUnixTimestamp(value: unknown): number | null {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
private toIsoString(timestamp: number | null): string | null {
|
||||
return timestamp ? new Date(timestamp * 1000).toISOString() : null;
|
||||
}
|
||||
|
||||
private normalizeDateString(value: unknown): string {
|
||||
const rawValue = String(value ?? '').trim();
|
||||
if (!rawValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = Date.parse(rawValue.replace(' ', 'T'));
|
||||
return Number.isFinite(parsed)
|
||||
? new Date(parsed).toISOString()
|
||||
: rawValue;
|
||||
}
|
||||
|
||||
private getEpgItemTimestampMs(
|
||||
isoValue: string,
|
||||
unixTimestampValue: string
|
||||
): number {
|
||||
const unixTimestamp = this.parseUnixTimestamp(unixTimestampValue);
|
||||
if (unixTimestamp) {
|
||||
return unixTimestamp * 1000;
|
||||
}
|
||||
|
||||
return Date.parse(isoValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request via IPC to avoid CORS issues
|
||||
*/
|
||||
private async sendRequest<TResponse>(
|
||||
url: string,
|
||||
params: Record<string, string | number>,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<TResponse> {
|
||||
const serializedParams: Record<string, string> = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
serializedParams[key] = String(value);
|
||||
});
|
||||
|
||||
const response = (await this.dataService.sendIpcEvent(XTREAM_REQUEST, {
|
||||
url,
|
||||
params: serializedParams,
|
||||
requestId: options?.requestId,
|
||||
sessionId: options?.sessionId,
|
||||
suppressErrorLog: options?.suppressErrorLog,
|
||||
})) as {
|
||||
message?: string;
|
||||
payload?: unknown;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
// The IPC layer catches errors and returns { type: 'ERROR', message, status }
|
||||
// instead of rejecting. Convert that back into a thrown error so callers
|
||||
// can handle it with .catch() / try-catch.
|
||||
if (response?.type === 'ERROR' || (!response?.payload && response?.message)) {
|
||||
throw new Error(response?.message ?? 'Request failed');
|
||||
}
|
||||
|
||||
return response?.payload as TResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DatabaseService, SettingsStore } from 'services';
|
||||
import { XtreamCredentials } from './xtream-api.service';
|
||||
import { XtreamUrlService } from './xtream-url.service';
|
||||
|
||||
describe('XtreamUrlService', () => {
|
||||
let service: XtreamUrlService;
|
||||
let databaseService: {
|
||||
getAppState: jest.Mock<Promise<string | null>, [string]>;
|
||||
setAppState: jest.Mock<Promise<void>, [string, string]>;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: 'http://demo.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(() => {
|
||||
databaseService = {
|
||||
getAppState: jest.fn().mockResolvedValue(null),
|
||||
setAppState: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
XtreamUrlService,
|
||||
{ provide: DatabaseService, useValue: databaseService },
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
streamFormat: signal('ts'),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(XtreamUrlService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('detects the legacy catchup scheme once and then uses the cached result', async () => {
|
||||
const xtreamProbeUrl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: 404 })
|
||||
.mockResolvedValueOnce({ status: 302 });
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
|
||||
const firstUrl = await service.resolveCatchupUrl(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
101,
|
||||
1775296800,
|
||||
1775300400
|
||||
);
|
||||
const secondUrl = await service.resolveCatchupUrl(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
101,
|
||||
1775296800,
|
||||
1775300400
|
||||
);
|
||||
|
||||
expect(firstUrl).toContain('/streaming/timeshift.php?');
|
||||
expect(secondUrl).toBe(firstUrl);
|
||||
expect(xtreamProbeUrl).toHaveBeenCalledTimes(2);
|
||||
expect(databaseService.setAppState).toHaveBeenCalledWith(
|
||||
'xtream-catchup-scheme:playlist-1',
|
||||
'legacy'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
XtreamSerieEpisode,
|
||||
XtreamVodDetails,
|
||||
} from 'shared-interfaces';
|
||||
import { DatabaseService, SettingsStore } from 'services';
|
||||
import { XtreamCredentials } from './xtream-api.service';
|
||||
|
||||
/**
|
||||
* Extended playlist with optional HTTP headers
|
||||
*/
|
||||
export interface XtreamPlaylistWithHeaders extends XtreamCredentials {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live stream item with xtream_id
|
||||
*/
|
||||
export interface LiveStreamItem {
|
||||
xtream_id: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type XtreamVodStreamLike = XtreamVodDetails & {
|
||||
readonly stream_id?: number;
|
||||
};
|
||||
|
||||
type XtreamCatchupScheme = 'rest' | 'legacy';
|
||||
|
||||
type XtreamProbeApi = {
|
||||
xtreamProbeUrl?: (
|
||||
url: string,
|
||||
method?: 'GET' | 'HEAD'
|
||||
) => Promise<{ status: number }>;
|
||||
};
|
||||
|
||||
const XTREAM_CATCHUP_SCHEME_KEY_PREFIX = 'xtream-catchup-scheme:';
|
||||
|
||||
/**
|
||||
* Service for constructing Xtream stream URLs.
|
||||
* Handles URL construction for live streams, VOD, and series episodes.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class XtreamUrlService {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly catchupSchemeCache = new Map<string, XtreamCatchupScheme>();
|
||||
private readonly catchupSchemeRequests = new Map<
|
||||
string,
|
||||
Promise<XtreamCatchupScheme>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Construct live stream URL
|
||||
* Format: {serverUrl}/live/{username}/{password}/{streamId}.{format}
|
||||
*/
|
||||
constructLiveUrl(
|
||||
credentials: XtreamCredentials,
|
||||
xtreamId: number,
|
||||
format?: string
|
||||
): string {
|
||||
const streamFormat =
|
||||
format ?? this.settingsStore.streamFormat() ?? 'ts';
|
||||
return `${credentials.serverUrl}/live/${credentials.username}/${credentials.password}/${xtreamId}.${streamFormat}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct VOD stream URL
|
||||
* Format: {serverUrl}/movie/{username}/{password}/{streamId}.{extension}
|
||||
*/
|
||||
constructVodUrl(
|
||||
credentials: XtreamCredentials,
|
||||
vodItem: XtreamVodDetails
|
||||
): string {
|
||||
const vod = vodItem as XtreamVodStreamLike;
|
||||
const streamId = vod.movie_data?.stream_id ?? vod.stream_id;
|
||||
const extension = vodItem.movie_data?.container_extension;
|
||||
if (!streamId || !extension) {
|
||||
return '';
|
||||
}
|
||||
return `${credentials.serverUrl}/movie/${credentials.username}/${credentials.password}/${streamId}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct series episode stream URL
|
||||
* Format: {serverUrl}/series/{username}/{password}/{episodeId}.{extension}
|
||||
*/
|
||||
constructEpisodeUrl(
|
||||
credentials: XtreamCredentials,
|
||||
episode: XtreamSerieEpisode
|
||||
): string {
|
||||
return `${credentials.serverUrl}/series/${credentials.username}/${credentials.password}/${episode.id}.${episode.container_extension}`;
|
||||
}
|
||||
|
||||
constructCatchupUrl(
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number,
|
||||
scheme: XtreamCatchupScheme
|
||||
): string {
|
||||
const durationMinutes = Math.max(
|
||||
1,
|
||||
Math.round((stopTimestamp - startTimestamp) / 60)
|
||||
);
|
||||
const timeString = this.formatCatchupStartTime(startTimestamp);
|
||||
|
||||
if (scheme === 'legacy') {
|
||||
const params = new URLSearchParams({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
stream: String(streamId),
|
||||
start: timeString,
|
||||
duration: String(durationMinutes),
|
||||
});
|
||||
return `${credentials.serverUrl}/streaming/timeshift.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
return `${credentials.serverUrl}/timeshift/${credentials.username}/${credentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
|
||||
}
|
||||
|
||||
async resolveCatchupUrl(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): Promise<string> {
|
||||
const scheme = await this.getCatchupScheme(
|
||||
playlistId,
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp
|
||||
);
|
||||
|
||||
return this.constructCatchupUrl(
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
scheme
|
||||
);
|
||||
}
|
||||
|
||||
private async getCatchupScheme(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): Promise<XtreamCatchupScheme> {
|
||||
const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`;
|
||||
const cached = this.catchupSchemeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const persisted = await this.databaseService.getAppState(cacheKey);
|
||||
if (persisted === 'rest' || persisted === 'legacy') {
|
||||
this.catchupSchemeCache.set(cacheKey, persisted);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
const inFlightRequest = this.catchupSchemeRequests.get(cacheKey);
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
const request = this.detectCatchupScheme(
|
||||
cacheKey,
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp
|
||||
).finally(() => {
|
||||
this.catchupSchemeRequests.delete(cacheKey);
|
||||
});
|
||||
|
||||
this.catchupSchemeRequests.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
private async detectCatchupScheme(
|
||||
cacheKey: string,
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): Promise<XtreamCatchupScheme> {
|
||||
const restUrl = this.constructCatchupUrl(
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
'rest'
|
||||
);
|
||||
const legacyUrl = this.constructCatchupUrl(
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
'legacy'
|
||||
);
|
||||
|
||||
const restStatus = await this.probeCatchupUrl(restUrl);
|
||||
let detectedScheme: XtreamCatchupScheme;
|
||||
|
||||
if (this.isAcceptedCatchupProbeStatus(restStatus)) {
|
||||
detectedScheme = 'rest';
|
||||
} else {
|
||||
const legacyStatus = await this.probeCatchupUrl(legacyUrl);
|
||||
detectedScheme = this.isAcceptedCatchupProbeStatus(legacyStatus)
|
||||
? 'legacy'
|
||||
: restStatus === 404
|
||||
? 'legacy'
|
||||
: 'rest';
|
||||
}
|
||||
|
||||
this.catchupSchemeCache.set(cacheKey, detectedScheme);
|
||||
await this.databaseService.setAppState(cacheKey, detectedScheme);
|
||||
return detectedScheme;
|
||||
}
|
||||
|
||||
private async probeCatchupUrl(url: string): Promise<number> {
|
||||
const probeUrl = (window.electron as XtreamProbeApi | undefined)
|
||||
?.xtreamProbeUrl;
|
||||
|
||||
if (typeof probeUrl !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await probeUrl(url, 'HEAD');
|
||||
return Number(result?.status ?? 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private isAcceptedCatchupProbeStatus(status: number): boolean {
|
||||
return (
|
||||
(status >= 200 && status < 400) ||
|
||||
status === 401 ||
|
||||
status === 403 ||
|
||||
status === 405
|
||||
);
|
||||
}
|
||||
|
||||
private formatCatchupStartTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user