From aa27e6da0decf24d39e68dbeb89bfb41c6d4bc04 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Jan 2026 06:25:12 +0100 Subject: [PATCH] refactor: add xtream api services --- .../src/app/xtream-tauri/services/index.ts | 2 + .../services/xtream-api.service.ts | 259 ++++++++++++++++++ .../services/xtream-url.service.ts | 75 +++++ 3 files changed, 336 insertions(+) create mode 100644 apps/web/src/app/xtream-tauri/services/index.ts create mode 100644 apps/web/src/app/xtream-tauri/services/xtream-api.service.ts create mode 100644 apps/web/src/app/xtream-tauri/services/xtream-url.service.ts diff --git a/apps/web/src/app/xtream-tauri/services/index.ts b/apps/web/src/app/xtream-tauri/services/index.ts new file mode 100644 index 000000000..76fcc2ad2 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/services/index.ts @@ -0,0 +1,2 @@ +export * from './xtream-api.service'; +export * from './xtream-url.service'; diff --git a/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts b/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts new file mode 100644 index 000000000..0c5a0bd14 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts @@ -0,0 +1,259 @@ +import { inject, Injectable } from '@angular/core'; +import { DataService } from 'services'; +import { + EpgItem, + XtreamCategory, + XtreamCodeActions, + XtreamLiveStream, + XtreamSerieDetails, + XtreamSerieItem, + XtreamVodDetails, + XtreamVodStream, +} 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'; + +/** + * Raw EPG listing from API (before decoding) + */ +interface RawEpgListing { + title: string; + description: string; + start: string; + end: string; + start_timestamp: string; + stop_timestamp: string; + [key: string]: unknown; +} + +/** + * EPG API response + */ +interface EpgResponse { + epg_listings?: 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); + + /** + * Get account/portal status + */ + async getAccountInfo( + credentials: XtreamCredentials + ): Promise { + return this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + username: credentials.username, + password: credentials.password, + action: 'get_account_info', + } + ); + } + + /** + * Get categories by type + */ + async getCategories( + credentials: XtreamCredentials, + type: CategoryType + ): Promise { + const actionMap: Record = { + live: XtreamCodeActions.GetLiveCategories, + vod: XtreamCodeActions.GetVodCategories, + series: XtreamCodeActions.GetSeriesCategories, + }; + + const response = await this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + action: actionMap[type], + username: credentials.username, + password: credentials.password, + } + ); + + return Array.isArray(response) ? response : []; + } + + /** + * Get live streams + */ + async getLiveStreams( + credentials: XtreamCredentials + ): Promise { + const response = await this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + action: XtreamCodeActions.GetLiveStreams, + username: credentials.username, + password: credentials.password, + } + ); + + return Array.isArray(response) ? response : []; + } + + /** + * Get VOD streams + */ + async getVodStreams( + credentials: XtreamCredentials + ): Promise { + const response = await this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + action: XtreamCodeActions.GetVodStreams, + username: credentials.username, + password: credentials.password, + } + ); + + return Array.isArray(response) ? response : []; + } + + /** + * Get series items + */ + async getSeriesStreams( + credentials: XtreamCredentials + ): Promise { + const response = await this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + action: XtreamCodeActions.GetSeries, + username: credentials.username, + password: credentials.password, + } + ); + + return Array.isArray(response) ? response : []; + } + + /** + * Get streams by type (unified method) + */ + async getStreams( + credentials: XtreamCredentials, + type: StreamType + ): Promise { + switch (type) { + case 'live': + return this.getLiveStreams(credentials); + case 'movie': + return this.getVodStreams(credentials); + case 'series': + return this.getSeriesStreams(credentials); + } + } + + /** + * Get VOD details + */ + async getVodInfo( + credentials: XtreamCredentials, + vodId: string | number + ): Promise { + return this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + action: XtreamCodeActions.GetVodInfo, + username: credentials.username, + password: credentials.password, + vod_id: vodId, + } + ); + } + + /** + * Get series details + */ + async getSeriesInfo( + credentials: XtreamCredentials, + seriesId: string | number + ): Promise { + return this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + action: XtreamCodeActions.GetSeriesInfo, + username: credentials.username, + password: credentials.password, + series_id: seriesId, + } + ); + } + + /** + * Get short EPG for a stream + * Returns decoded EPG items (title and description are base64 decoded) + */ + async getShortEpg( + credentials: XtreamCredentials, + streamId: number, + limit: number = 10 + ): Promise { + const response: EpgResponse = await this.dataService.fetchData( + `${credentials.serverUrl}/player_api.php`, + { + action: XtreamCodeActions.GetShortEpg, + username: credentials.username, + password: credentials.password, + stream_id: streamId, + limit, + } + ); + + if (!response?.epg_listings || !Array.isArray(response.epg_listings)) { + return []; + } + + return response.epg_listings.map((item) => ({ + ...item, + title: this.decodeBase64Unicode(item.title).trim(), + description: this.decodeBase64Unicode(item.description).trim(), + })) as EpgItem[]; + } + + /** + * 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; + } + } +} diff --git a/apps/web/src/app/xtream-tauri/services/xtream-url.service.ts b/apps/web/src/app/xtream-tauri/services/xtream-url.service.ts new file mode 100644 index 000000000..c3e874b6c --- /dev/null +++ b/apps/web/src/app/xtream-tauri/services/xtream-url.service.ts @@ -0,0 +1,75 @@ +import { inject, Injectable } from '@angular/core'; +import { + XtreamSerieEpisode, + XtreamVodDetails, +} from 'shared-interfaces'; +import { SettingsStore } from '../../services/settings-store.service'; +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; +} + +/** + * Service for constructing Xtream stream URLs. + * Handles URL construction for live streams, VOD, and series episodes. + */ +@Injectable({ providedIn: 'root' }) +export class XtreamUrlService { + private readonly settingsStore = inject(SettingsStore); + + /** + * 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 streamId = + vodItem.movie_data.stream_id ?? (vodItem as any).stream_id; + const extension = vodItem.movie_data.container_extension; + 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}`; + } +}