Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
@@ -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())}`;
}
}