diff --git a/apps/web/src/app/services/electron.service.stub.ts b/apps/web/src/app/services/electron.service.stub.ts index eee603144..f6bbffa7f 100644 --- a/apps/web/src/app/services/electron.service.stub.ts +++ b/apps/web/src/app/services/electron.service.stub.ts @@ -16,15 +16,29 @@ export class ElectronServiceStub { isElectron = true; - removeAllListeners(type: string) {} + removeAllListeners(_type: string): void { + void _type; + return undefined; + } getAppVersion() { return this.remote.app.getVersion(); } - sendIpcEvent() {} + sendIpcEvent(_type: string, _payload?: unknown): void { + void _type; + void _payload; + return undefined; + } - listenOn() {} + listenOn( + _command: string, + _callback: (...args: unknown[]) => void + ): void { + void _command; + void _callback; + return undefined; + } getAppEnvironment() { return 'electron'; diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 74af394ce..e7c5edcfd 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -20,6 +20,23 @@ import { logPortalDebugEvent, } from '@iptvnator/portal/shared/util'; +interface PlayerLaunchPayload { + readonly headers?: Record; + readonly origin?: string; + readonly referer?: string; + readonly startTime?: number; + readonly thumbnail?: string; + readonly title?: string; + readonly url: string; + readonly ['user-agent']?: string; + readonly contentInfo?: unknown; +} + +interface ErrorStatus { + readonly message?: string; + readonly status?: number; +} + @Injectable({ providedIn: 'root', }) @@ -66,10 +83,15 @@ export class ElectronService extends DataService { } private setupPortalDebugListener() { - const onPortalDebugEvent = (window.electron as any) - ?.onPortalDebugEvent as - | ((callback: (event: unknown) => void) => void) - | undefined; + const onPortalDebugEvent = ( + window.electron as { + onPortalDebugEvent?: ( + callback: ( + event: Parameters[0] + ) => void + ) => void; + } + ).onPortalDebugEvent; if (AppConfig.production || !onPortalDebugEvent) { return; @@ -111,7 +133,7 @@ export class ElectronService extends DataService { } ); } else if (type === 'OPEN_MPV_PLAYER') { - const data = payload as any; + const data = payload as PlayerLaunchPayload; try { return await window.electron.openInMpv( data.url, @@ -125,8 +147,9 @@ export class ElectronService extends DataService { data.headers ?? undefined ); /* thumbnail: data.thumbnail ?? '', */ - } catch (error: any) { - const errorMessage = error?.message || String(error); + } catch (error: unknown) { + const errorMessage = + this.getErrorDetails(error)?.message ?? String(error); this.snackBar.open( `Error launching MPV: ${errorMessage}`, 'Close', @@ -138,7 +161,7 @@ export class ElectronService extends DataService { throw error; } } else if (type === 'OPEN_VLC_PLAYER') { - const data = payload as any; + const data = payload as PlayerLaunchPayload; try { return await window.electron.openInVlc( data.url, @@ -151,8 +174,9 @@ export class ElectronService extends DataService { data.startTime, data.headers ?? undefined ); - } catch (error: any) { - const errorMessage = error?.message || String(error); + } catch (error: unknown) { + const errorMessage = + this.getErrorDetails(error)?.message ?? String(error); this.snackBar.open( `Error launching VLC: ${errorMessage}`, 'Close', @@ -187,6 +211,7 @@ export class ElectronService extends DataService { url: string; macAddress: string; params: Record; + requestId?: string; token?: string; serialNumber?: string; }) { @@ -202,12 +227,13 @@ export class ElectronService extends DataService { const response = await window.electron.stalkerRequest({ ...payload, requestId: context.requestId, - } as any); + }); return response; - } catch (err) { + } catch (err: unknown) { + const errorInfo = this.getErrorDetails(err); console.error('Stalker request error:', err); this.snackBar.open( - `Error: ${err.message ?? ' Not found'}, status: ${err.status ?? 404}`, + `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, 'Close', { duration: 5000, @@ -329,6 +355,7 @@ export class ElectronService extends DataService { private async forwardXtreamRequest(payload: { url: string; params: Record; + requestId?: string; }) { const context = createPortalDebugRequestContext({ provider: 'xtream', @@ -342,7 +369,7 @@ export class ElectronService extends DataService { const response = await window.electron.xtreamRequest({ ...payload, requestId: context.requestId, - } as any); + }); const result = { type: XTREAM_RESPONSE, @@ -351,12 +378,13 @@ export class ElectronService extends DataService { }; window.postMessage(result); return result; - } catch (error: any) { + } catch (error: unknown) { const action = payload.params?.action; const isSilentAction = action ? this.silentXtreamActions.has(action) : false; const normalizedMessage = this.getReadableXtreamErrorMessage(error); + const errorInfo = this.getErrorDetails(error); // Log error to console if (isSilentAction) { @@ -381,7 +409,7 @@ export class ElectronService extends DataService { return { type: ERROR, - status: error.status ?? 500, + status: errorInfo?.status ?? 500, message: normalizedMessage, }; } @@ -431,6 +459,13 @@ export class ElectronService extends DataService { return fallback; } + private getErrorDetails(error: unknown): ErrorStatus | null { + if (error && typeof error === 'object') { + return error as ErrorStatus; + } + return null; + } + removeAllListeners(type: string): void { if (type === 'all') { // Unsubscribe from all event listeners @@ -448,14 +483,16 @@ export class ElectronService extends DataService { window.removeEventListener('message', this.getListenerForCommand(type)); } - private getListenerForCommand(command: string): any { + private getListenerForCommand(_command: string): EventListener { + void _command; // This is a placeholder. In a real implementation, you would need to // store the actual listener functions to be able to remove them - return () => {}; + return () => undefined; } - listenOn(command: string, callback: (...args: any[]) => void): void { + listenOn(command: string, callback: (...args: unknown[]) => void): void { // For Electron, use window message events + void command; window.addEventListener('message', callback); } diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 54e2a81b6..e90d446d9 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -1,7 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Params } from '@angular/router'; import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; @@ -27,6 +26,28 @@ import { logPortalDebugRequest, } from '@iptvnator/portal/shared/util'; +interface PwaXtreamResponse { + readonly payload?: unknown; + readonly status?: number; +} + +interface PwaXtreamResult { + readonly action: string; + readonly payload: unknown; + readonly type: typeof XTREAM_RESPONSE; +} + +interface PwaErrorResult { + readonly message: string; + readonly status: number; + readonly type: typeof ERROR; +} + +interface ErrorStatus { + readonly message?: string; + readonly status?: number; +} + @Injectable({ providedIn: 'root', }) @@ -211,18 +232,18 @@ export class PwaService extends DataService { logPortalDebugRequest(context); try { - let result: any; - const response = await firstValueFrom( - this.http.get(`${this.corsProxyUrl}/xtream`, { + let result: PwaErrorResult | PwaXtreamResult; + const response = (await firstValueFrom( + this.http.get(`${this.corsProxyUrl}/xtream`, { params: { url: payload.url, ...payload.params, }, ...headers, }) - ); + )) as PwaXtreamResponse; - if (!(response as any).payload) { + if (!response.payload) { const action = payload.params.action; const isSilentAction = this.silentXtreamActions.has(action); const normalizedMessage = this.getReadableXtreamErrorMessage( @@ -239,21 +260,21 @@ export class PwaService extends DataService { ); return { type: ERROR, - status: (response as any).status ?? 500, + status: response.status ?? 500, message: normalizedMessage, }; } result = { type: ERROR, - status: (response as any).status, + status: response.status ?? 500, message: normalizedMessage, }; window.postMessage(result); } else { result = { type: XTREAM_RESPONSE, - payload: (response as any).payload, + payload: response.payload, action: payload.params.action, }; logPortalDebugEvent( @@ -262,11 +283,12 @@ export class PwaService extends DataService { window.postMessage(result); } return result; - } catch (error: any) { + } catch (error: unknown) { logPortalDebugEvent(createPortalDebugErrorEvent(context, error)); const action = payload.params.action; const isSilentAction = this.silentXtreamActions.has(action); const normalizedMessage = this.getReadableXtreamErrorMessage(error); + const errorInfo = this.getErrorDetails(error); // Log error to console if (isSilentAction) { @@ -276,7 +298,7 @@ export class PwaService extends DataService { ); return { type: ERROR, - status: error.status ?? 500, + status: errorInfo?.status ?? 500, message: normalizedMessage, }; } @@ -291,7 +313,7 @@ export class PwaService extends DataService { ); return { type: ERROR, - status: error.status ?? 500, + status: errorInfo?.status ?? 500, message: normalizedMessage, }; } @@ -339,6 +361,13 @@ export class PwaService extends DataService { return fallback; } + private getErrorDetails(error: unknown): ErrorStatus | null { + if (error && typeof error === 'object') { + return error as ErrorStatus; + } + return null; + } + async forwardStalkerRequest(payload: { url: string; params: Record; @@ -382,12 +411,13 @@ export class PwaService extends DataService { createPortalDebugSuccessEvent(context, responseBody) ); return responseBody.payload; - } catch (err) { + } catch (err: unknown) { + const errorInfo = this.getErrorDetails(err); logPortalDebugEvent(createPortalDebugErrorEvent(context, err)); console.error('Stalker request error:', err); this.snackBar.open( - `Error: ${err.message ?? ' Not found'}, status: ${err.status ?? 404}`, + `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, 'Close', { duration: 5000, @@ -407,7 +437,7 @@ export class PwaService extends DataService { // not implemented } - listenOn(_command: string, callback: (...args: any[]) => void): void { + listenOn(_command: string, callback: (...args: unknown[]) => void): void { window.addEventListener('message', callback); } diff --git a/apps/web/src/app/services/settings.service.spec.ts b/apps/web/src/app/services/settings.service.spec.ts index 5aa1d2906..1d36fbd36 100644 --- a/apps/web/src/app/services/settings.service.spec.ts +++ b/apps/web/src/app/services/settings.service.spec.ts @@ -2,8 +2,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; import { StorageMap } from '@ngx-pwa/local-storage'; import { of } from 'rxjs'; -import { STORE_KEY } from '../../../../../libs/shared/interfaces/src/lib/store-keys.enum'; -import { Theme } from '../../../../../libs/shared/interfaces/src/lib/theme.enum'; +import { STORE_KEY, Theme } from 'shared-interfaces'; import { SettingsService } from './settings.service'; describe('Service: Settings', () => { @@ -25,7 +24,7 @@ describe('Service: Settings', () => { [SettingsService, StorageMap], (service: SettingsService, storage: StorageMap) => { const version = '2.1.0'; - jest.spyOn(storage, 'set').mockReturnValue(of([] as any)); + jest.spyOn(storage, 'set').mockReturnValue(of(undefined)); service.setValueToLocalStorage(STORE_KEY.Version, version); expect(storage.set).toHaveBeenCalledWith( STORE_KEY.Version, diff --git a/apps/web/src/app/services/settings.service.ts b/apps/web/src/app/services/settings.service.ts index a4ec4d7bf..f8008be1f 100644 --- a/apps/web/src/app/services/settings.service.ts +++ b/apps/web/src/app/services/settings.service.ts @@ -43,12 +43,12 @@ export class SettingsService { key: STORE_KEY, value: unknown, withCallback = false - ): Observable | never { + ): Observable | void { if (withCallback) { return this.storage.set(key, value); - } else { - this.storage.set(key, value).subscribe(() => {}); } + + this.storage.set(key, value).subscribe(); } /** diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 023c0c3ab..a7b40032a 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -11,7 +11,7 @@ import { WORKSPACE_SHELL_ACTIONS, WorkspaceAccountInfoData, WorkspaceShellActions, -} from '@iptvnator/workspace/shell/feature'; +} from '@iptvnator/workspace/shell/util'; @Injectable({ providedIn: 'root' }) export class AppWorkspaceShellActionsService implements WorkspaceShellActions { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 02d712dd8..872e033b8 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -1,16 +1,13 @@ -/* eslint-disable @typescript-eslint/no-base-to-string */ import { CommonModule } from '@angular/common'; import { Component, ElementRef, effect, inject, - Inject, Injector, Input, OnDestroy, OnInit, - Optional, signal, } from '@angular/core'; import { @@ -49,7 +46,7 @@ import { Theme, VideoPlayer, } from 'shared-interfaces'; -import { SettingsContextService } from '@iptvnator/workspace/shell/feature'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsService } from './../services/settings.service'; @@ -98,8 +95,12 @@ export class SettingsComponent implements OnInit, OnDestroy { private matDialog = inject(MatDialog); private readonly elementRef = inject(ElementRef); private readonly injector = inject(Injector); + private readonly dialogData = inject<{ isDialog: boolean } | null>( + MAT_DIALOG_DATA, + { optional: true } + ); - @Input() isDialog = false; + @Input() isDialog = this.dialogData?.isDialog ?? false; /** List with available languages as enum */ readonly languageEnum = Language; @@ -229,11 +230,7 @@ export class SettingsComponent implements OnInit, OnDestroy { }, ]; - constructor( - @Optional() @Inject(MAT_DIALOG_DATA) data?: { isDialog: boolean } - ) { - this.isDialog = data?.isDialog ?? false; - + constructor() { effect( () => { const sectionId = this.settingsCtx.pendingScrollTarget(); diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 45c48177c..337fa0f4b 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -4,7 +4,24 @@ interface NodeModule { id: string; } -import { ExternalPlayerSession, PortalDebugEvent } from 'shared-interfaces'; +import { + EpgChannel, + EpgProgram, + ExternalPlayerSession, + PlaybackPositionData, + Playlist, + PortalDebugEvent, +} from 'shared-interfaces'; +import { + GlobalFavoriteItem, + GlobalRecentItem, + GlobalSearchResult, + XCategoryFromDb, + XtreamContent, +} from 'services'; + +type JsonObject = Record; +type JsonArray = unknown[]; declare global { interface Window { @@ -14,12 +31,15 @@ declare global { ) => () => void; getAppVersion: () => Promise; platform: string; - fetchPlaylistByUrl: (url: string, title?: string) => Promise; + fetchPlaylistByUrl: ( + url: string, + title?: string + ) => Promise; updatePlaylistFromFilePath: ( filePath: string, title: string - ) => Promise; - openPlaylistFromFile: () => Promise; + ) => Promise; + openPlaylistFromFile: () => Promise; saveFileDialog: ( defaultPath: string, filters?: { name: string; extensions: string[] }[] @@ -36,7 +56,7 @@ declare global { userAgent: string, referer?: string, origin?: string, - contentInfo?: any, + contentInfo?: unknown, startTime?: number, headers?: Record ) => Promise; @@ -47,11 +67,11 @@ declare global { userAgent: string, referer?: string, origin?: string, - contentInfo?: any, + contentInfo?: unknown, startTime?: number, headers?: Record ) => Promise; - autoUpdatePlaylists: (playlists: any[]) => Promise; + autoUpdatePlaylists: (playlists: Playlist[]) => Promise; fetchEpg: ( urls: string[] ) => Promise<{ @@ -59,12 +79,12 @@ declare global { message?: string; skipped?: string[]; }>; - getChannelPrograms: (channelId: string) => Promise; - getEpgChannels: () => Promise; + getChannelPrograms: (channelId: string) => Promise; + getEpgChannels: () => Promise; getEpgChannelsByRange: ( skip: number, limit: number - ) => Promise; + ) => Promise; forceFetchEpg: ( url: string ) => Promise<{ success: boolean; message?: string }>; @@ -76,8 +96,8 @@ declare global { searchEpgPrograms: ( searchTerm: string, limit?: number - ) => Promise; - updateSettings: (settings: any) => Promise; + ) => Promise; + updateSettings: (settings: JsonObject) => Promise; getAiSettings: () => Promise<{ aiProvider: string; aiModelName: string; @@ -92,26 +112,28 @@ declare global { token?: string; serialNumber?: string; requestId?: string; - }) => Promise; + }) => Promise; xtreamRequest: (payload: { url: string; params: Record; requestId?: string; - }) => Promise<{ payload: any; action: string }>; + }) => Promise<{ payload: unknown; action: string }>; // Database operations - dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; - dbGetPlaylist: (playlistId: string) => Promise; + dbCreatePlaylist: ( + playlist: Playlist + ) => Promise<{ success: boolean }>; + dbGetPlaylist: (playlistId: string) => Promise; dbUpsertAppPlaylist: ( - playlist: any + playlist: Playlist ) => Promise<{ success: boolean }>; dbUpsertAppPlaylists: ( - playlists: any[] + playlists: Playlist[] ) => Promise<{ success: boolean; count: number }>; - dbGetAppPlaylists: () => Promise; - dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylists: () => Promise; + dbGetAppPlaylist: (playlistId: string) => Promise; dbUpdatePlaylist: ( playlistId: string, - updates: any + updates: Partial ) => Promise<{ success: boolean }>; dbDeletePlaylist: ( playlistId: string @@ -143,17 +165,17 @@ declare global { dbGetCategories: ( playlistId: string, type: string - ) => Promise; + ) => Promise; dbSaveCategories: ( playlistId: string, - categories: any[], + categories: XCategoryFromDb[], type: string, hiddenCategoryXtreamIds?: number[] ) => Promise<{ success: boolean }>; dbGetAllCategories: ( playlistId: string, type: string - ) => Promise; + ) => Promise; dbUpdateCategoryVisibility: ( categoryIds: number[], hidden: boolean @@ -162,10 +184,13 @@ declare global { playlistId: string, type: string ) => Promise; - dbGetContent: (playlistId: string, type: string) => Promise; + dbGetContent: ( + playlistId: string, + type: string + ) => Promise; dbSaveContent: ( playlistId: string, - streams: any[], + streams: JsonArray, type: string ) => Promise<{ success: boolean; count: number }>; dbSearchContent: ( @@ -173,13 +198,13 @@ declare global { searchTerm: string, types: string[], excludeHidden?: boolean - ) => Promise; + ) => Promise; dbGlobalSearch: ( searchTerm: string, types: string[], excludeHidden?: boolean - ) => Promise; - dbGetRecentlyViewed: () => Promise; + ) => Promise; + dbGetRecentlyViewed: () => Promise; dbClearRecentlyViewed: () => Promise<{ success: boolean }>; // Favorites dbAddFavorite: ( @@ -194,13 +219,13 @@ declare global { contentId: number, playlistId: string ) => Promise; - dbGetFavorites: (playlistId: string) => Promise; - dbGetGlobalFavorites: () => Promise; + dbGetFavorites: (playlistId: string) => Promise; + dbGetGlobalFavorites: () => Promise; dbReorderGlobalFavorites: ( updates: { content_id: number; position: number }[] ) => Promise<{ success: boolean }>; // Recently viewed (playlist-specific) - dbGetRecentItems: (playlistId: string) => Promise; + dbGetRecentItems: (playlistId: string) => Promise; dbAddRecentItem: ( contentId: number, playlistId: string @@ -215,7 +240,7 @@ declare global { dbGetContentByXtreamId: ( xtreamId: number, playlistId: string - ) => Promise; + ) => Promise; dbGetAppState: (key: string) => Promise; dbSetAppState: ( key: string, @@ -293,23 +318,25 @@ declare global { playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode' - ) => Promise; + ) => Promise; dbGetSeriesPlaybackPositions: ( playlistId: string, seriesXtreamId: number - ) => Promise; + ) => Promise; dbGetRecentPlaybackPositions: ( playlistId: string, limit?: number - ) => Promise; - dbGetAllPlaybackPositions: (playlistId: string) => Promise; + ) => Promise; + dbGetAllPlaybackPositions: ( + playlistId: string + ) => Promise; dbClearPlaybackPosition: ( playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode' ) => Promise<{ success: boolean }>; onPlaybackPositionUpdate: ( - callback: (data: any) => void + callback: (data: PlaybackPositionData) => void ) => () => void; closeExternalPlayerSession: ( sessionId: string diff --git a/global.d.ts b/global.d.ts index 114f8f671..0d7ac7d6d 100644 --- a/global.d.ts +++ b/global.d.ts @@ -95,10 +95,12 @@ declare global { params: Record; token?: string; serialNumber?: string; + requestId?: string; }) => Promise; xtreamRequest: (payload: { url: string; params: Record; + requestId?: string; }) => Promise<{ payload: any; action: string }>; // Database operations dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index ae661b8d3..956c1bda8 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -58,6 +58,7 @@ import { import { DataService, PlaylistsService, SettingsStore } from 'services'; import { Channel, + EpgProgram, PLAYLIST_PARSE_BY_URL, STORE_KEY, Settings, @@ -301,6 +302,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return; } + const currentEpgProgram = epgProgram as EpgProgram | null | undefined; const currentIndex = channels.findIndex( (channel) => channel.url === activeChannel.url ); @@ -310,9 +312,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { isLiveView: true, channelName: activeChannel.name ?? activeChannel.tvg?.name, channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined, - epgTitle: (epgProgram as any)?.title, - epgStart: (epgProgram as any)?.start, - epgEnd: (epgProgram as any)?.end, + epgTitle: currentEpgProgram?.title, + epgStart: currentEpgProgram?.start, + epgEnd: currentEpgProgram?.stop, supportsVolume: true, volume: this.volume, muted: this.volume === 0, diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts index 49731e9db..3d27378fb 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts @@ -9,9 +9,16 @@ import { } from '@angular/core'; import { MatListModule } from '@angular/material/list'; import { TranslatePipe } from '@ngx-translate/core'; -import { XtreamCategory } from 'shared-interfaces'; import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component'; +interface CategoryViewItem { + readonly category_id?: string | number; + readonly category_name?: string; + readonly count?: number; + readonly id?: string | number; + readonly name?: string; +} + @Component({ selector: 'app-category-view', imports: [MatListModule, PlaylistErrorViewComponent, TranslatePipe], @@ -20,13 +27,13 @@ import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-erro styleUrls: ['./category-view.component.scss'], }) export class CategoryViewComponent { - readonly items = input([]); + readonly items = input([]); readonly selectedCategoryId = input(); readonly itemCounts = input>(new Map()); readonly showCounts = input(false); private readonly hostEl = inject(ElementRef); - readonly categoryClicked = output(); + readonly categoryClicked = output(); constructor() { effect(() => { @@ -50,18 +57,21 @@ export class CategoryViewComponent { }); } - isSelected(item: XtreamCategory): boolean { + isSelected(item: CategoryViewItem): boolean { const selectedCategory = this.selectedCategoryId(); - const itemId = (item as any).category_id ?? item.id; + const itemId = item.category_id ?? item.id; // Compare as strings to handle both numeric and string category IDs. - return selectedCategory != null && String(selectedCategory) === String(itemId); + return ( + selectedCategory != null && + String(selectedCategory) === String(itemId) + ); } - getItemCount(item: XtreamCategory): number { + getItemCount(item: CategoryViewItem): number { // content.category_id references categories.id (internal DB id) // For DB format categories, use id; for API format, use category_id - const itemId = Number(item.id ?? (item as any).category_id); + const itemId = Number(item.id ?? item.category_id); return this.itemCounts().get(itemId) ?? 0; } } diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts index b39e18881..a4ecc18e1 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts @@ -6,9 +6,39 @@ import { ActivatedRoute } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistSwitcherComponent, ResizableDirective } from 'components'; import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util'; +import { StalkerPortalItem, XtreamCategory } from 'shared-interfaces'; import { CategoryViewComponent } from '../category-view/category-view.component'; import { ContentCardComponent } from '../content-card/content-card.component'; +interface FavoriteLayoutSelection { + readonly category_id?: string | number; +} + +export interface FavoriteLayoutItem { + readonly added_at?: string | number; + readonly content_id?: number; + readonly category_id?: string | number; + readonly cover?: string; + readonly details?: { + readonly info?: { + readonly name?: string; + }; + }; + readonly id?: string | number; + readonly name?: string; + readonly o_name?: string; + readonly playlist_id?: string; + readonly playlist_name?: string; + readonly poster_url?: string; + readonly source?: 'xtream' | 'stalker'; + readonly stalker_item?: StalkerPortalItem; + readonly stream_icon?: string; + readonly title?: string; + readonly type?: string; + readonly viewed_at?: string; + readonly xtream_id?: string | number; +} + @Component({ selector: 'app-favorites-layout', templateUrl: './favorites-layout.component.html', @@ -32,8 +62,8 @@ export class FavoritesLayoutComponent { inject(ActivatedRoute) ); - readonly categories = input([]); - readonly favorites = input([]); + readonly categories = input([]); + readonly favorites = input([]); readonly playlistSubtitle = input(''); readonly playlistTitle = input('Playlist'); readonly selectedCategoryId = input('movie'); @@ -44,20 +74,20 @@ export class FavoritesLayoutComponent { readonly headerActionIcon = input('delete_sweep'); readonly headerActionTooltip = input(''); - readonly categoryClicked = output(); - readonly removeFavorite = output(); - readonly openItem = output(); + readonly categoryClicked = output(); + readonly removeFavorite = output(); + readonly openItem = output(); readonly headerActionClicked = output(); - setCategoryId(categoryId: any) { + setCategoryId(categoryId: string | number) { this.categoryClicked.emit({ category_id: categoryId }); } - removeFromFavorites(item: any) { + removeFromFavorites(item: FavoriteLayoutItem) { this.removeFavorite.emit(item); } - openFavorite(item: any) { + openFavorite(item: FavoriteLayoutItem) { this.openItem.emit(item); } diff --git a/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts b/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts index 3f54f713a..df456a938 100644 --- a/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts +++ b/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts @@ -1,6 +1,14 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { XtreamCategory } from 'shared-interfaces'; -import { FavoritesLayoutComponent } from '../favorites-layout/favorites-layout.component'; +import { + FavoriteLayoutItem, + FavoritesLayoutComponent, +} from '../favorites-layout/favorites-layout.component'; + +interface PortalCategorySelection { + readonly category_id?: string | number; + readonly id?: string | number; +} export interface PortalCollectionShellLayout { titleTranslationKey?: string; @@ -30,7 +38,7 @@ export type PortalCollectionMode = 'grid' | 'detail' | 'live'; }) export class PortalCollectionShellComponent { readonly categories = input([]); - readonly items = input([]); + readonly items = input([]); readonly playlistSubtitle = input(''); readonly playlistTitle = input('Playlist'); readonly selectedCategoryId = input('all'); @@ -38,15 +46,16 @@ export class PortalCollectionShellComponent { readonly mode = input('grid'); readonly categoryClicked = output(); - readonly removeItem = output(); - readonly openItem = output(); + readonly removeItem = output(); + readonly openItem = output(); readonly headerActionClicked = output(); - onCategoryClicked(event: { category_id?: string }) { - if (!event?.category_id) { + onCategoryClicked(event: PortalCategorySelection): void { + const categoryId = event.category_id ?? event.id; + if (categoryId == null) { return; } - this.categoryClicked.emit(event.category_id); + this.categoryClicked.emit(String(categoryId)); } } diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts index 96ed7297c..284810ec4 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, - ElementRef, input, output, viewChild, diff --git a/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts b/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts index 8d1d58a46..fdd19496b 100644 --- a/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts +++ b/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts @@ -12,6 +12,7 @@ import { PlaylistsService } from 'services'; import { EpgItem, VodDetailsItem } from 'shared-interfaces'; import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui'; import { + FavoriteLayoutItem, PortalCollectionMode, PortalCollectionShellComponent, PortalCollectionShellLayout, @@ -104,9 +105,7 @@ export class RecentlyViewedComponent { itemDetails: StalkerSelectedVodItem | null = null; vodDetailsItem: VodDetailsItem | null = null; readonly isSelectedVodFavorite = signal(false); - readonly selectedLiveItem = signal< - (StalkerVodSource & { category_id: string }) | null - >(null); + readonly selectedLiveItem = signal(null); readonly liveStreamUrl = signal(''); readonly epgItems = signal([]); readonly isLoadingEpg = signal(false); @@ -165,7 +164,8 @@ export class RecentlyViewedComponent { }, searchTerm: this.searchTerm(), liveCategoryId: 'itv', - textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`, + textOf: (item: StalkerVodSource) => + `${item.name ?? ''} ${item.o_name ?? ''}`, }); }); @@ -336,25 +336,30 @@ export class RecentlyViewedComponent { this.collectionContext.setCategoryId(categoryId); } - openItem(item: StalkerVodSource & { category_id: string }) { - if (!RecentlyViewedComponent.isCategoryType(item.category_id)) { + openItem(item: FavoriteLayoutItem) { + const categoryId = String(item.category_id ?? ''); + if (!RecentlyViewedComponent.isCategoryType(categoryId)) { return; } - this.stalkerStore.setSelectedContentType(item.category_id); - switch (item.category_id) { + this.stalkerStore.setSelectedContentType(categoryId); + switch (categoryId) { case 'itv': { this.setCategoryId('itv'); const cleared = clearStalkerDetailViewState(); this.itemDetails = cleared.itemDetails; this.vodDetailsItem = cleared.vodDetailsItem; this.isSelectedVodFavorite.set(false); - void this.selectLiveItem(item); + void this.selectLiveItem( + item as StalkerVodSource & { category_id: string } + ); break; } case 'vod': { // Normalize the item to ensure is_series flag is properly set - const normalizedItem = this.normalizeRecentItem(item); + const normalizedItem = this.normalizeRecentItem( + item as StalkerVodSource & { category_id: string } + ); const detailViewState = createStalkerDetailViewState( normalizedItem, this.currentPlaylist()?._id ?? '' @@ -369,7 +374,9 @@ export class RecentlyViewedComponent { break; } case 'series': - this.itemDetails = this.normalizeRecentItem(item); + this.itemDetails = this.normalizeRecentItem( + item as StalkerVodSource & { category_id: string } + ); this.stalkerStore.setSelectedItem(this.itemDetails); break; default: @@ -409,17 +416,15 @@ export class RecentlyViewedComponent { this.clearDetailsView(); } - async selectLiveItem(item: StalkerVodSource & { category_id: string }) { + async selectLiveItem(item: StalkerVodSource) { this.selectedLiveItem.set(item); this.liveStreamUrl.set(''); this.stalkerStore.setSelectedContentType('itv'); - this.stalkerStore.setSelectedItem(item as any); + this.stalkerStore.setSelectedItem(item); this.isResolvingPlayback.set(true); try { - const playback = await this.stalkerStore.resolveItvPlayback( - item as any - ); + const playback = await this.stalkerStore.resolveItvPlayback(item); await this.loadEpgForChannel(item.id); this.liveStreamUrl.set(playback.streamUrl); @@ -476,7 +481,7 @@ export class RecentlyViewedComponent { this.stalkerStore.removeFromFavorites(favoriteId, onDone); } - toggleLiveFavorite(item: StalkerVodSource & { category_id: string }): void { + toggleLiveFavorite(item: StalkerVodSource): void { const itemId = String(item.id ?? ''); if (this.liveFavoriteIds().has(itemId)) { this.removeFromFavorites(itemId); diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts index c0c2e1c76..539219850 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts @@ -29,8 +29,8 @@ export class StalkerCollectionChannelsListComponent { readonly selectedItemId = input(null); readonly favoriteIds = input>(new Map()); - readonly playClicked = output(); - readonly favoriteToggled = output(); + readonly playClicked = output(); + readonly favoriteToggled = output(); private readonly stalkerStore = inject(StalkerStore); protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts index 316d35c56..8558b6c60 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts @@ -12,6 +12,7 @@ import { PlaylistsService } from 'services'; import { EpgItem, VodDetailsItem } from 'shared-interfaces'; import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui'; import { + FavoriteLayoutItem, PortalCollectionMode, PortalCollectionShellComponent, PortalCollectionShellLayout, @@ -39,6 +40,7 @@ import { NormalizedStalkerFavoriteItem, normalizeStalkerEntityId, normalizeStalkerFavoriteItem, + StalkerVodSource, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component'; @@ -98,7 +100,7 @@ export class StalkerFavoritesComponent { itemDetails: NormalizedStalkerFavoriteItem | null = null; vodDetailsItem: VodDetailsItem | null = null; readonly isSelectedVodFavorite = signal(false); - readonly selectedLiveItem = signal(null); + readonly selectedLiveItem = signal(null); readonly liveStreamUrl = signal(''); readonly epgItems = signal([]); readonly isLoadingEpg = signal(false); @@ -174,7 +176,8 @@ export class StalkerFavoritesComponent { }, searchTerm: this.searchTerm(), liveCategoryId: 'itv', - textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`, + textOf: (item: StalkerFavoriteItem) => + `${item.name ?? ''} ${item.o_name ?? ''}`, }); }); @@ -302,7 +305,7 @@ export class StalkerFavoritesComponent { this.collectionContext.setCategoryId(categoryId); } - openItem(item: StalkerFavoriteItem) { + openItem(item: FavoriteLayoutItem) { this.logger.debug('Open item', item); const normalizedCategory = item.category_id === 'movie' ? 'vod' : item.category_id; @@ -411,17 +414,15 @@ export class StalkerFavoritesComponent { return this.inlineDetail().categoryId !== null; } - async selectLiveItem(item: StalkerFavoriteItem) { + async selectLiveItem(item: StalkerVodSource) { this.selectedLiveItem.set(item); this.liveStreamUrl.set(''); this.stalkerStore.setSelectedContentType('itv'); - this.stalkerStore.setSelectedItem(item as any); + this.stalkerStore.setSelectedItem(item); this.isResolvingPlayback.set(true); try { - const playback = await this.stalkerStore.resolveItvPlayback( - item as any - ); + const playback = await this.stalkerStore.resolveItvPlayback(item); await this.loadEpgForChannel(item.id); this.liveStreamUrl.set(playback.streamUrl); @@ -470,7 +471,7 @@ export class StalkerFavoritesComponent { await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); } - toggleLiveFavorite(item: StalkerFavoriteItem): void { + toggleLiveFavorite(item: StalkerVodSource): void { const itemId = normalizeStalkerEntityId(item.id); if (this.liveFavoriteIds().has(itemId)) { this.removeFromFavorites({ id: item.id }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 975f095f4..edf143d02 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -43,7 +43,6 @@ import { PortalEmptyStateComponent, } from '@iptvnator/portal/shared/ui'; import { - StalkerCategoryItem, StalkerFavoriteItem, StalkerItvChannel, normalizeStalkerEntityId, @@ -252,8 +251,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - selectCategory(item: StalkerCategoryItem) { - this.stalkerStore.setSelectedCategory(item.category_id || '*'); + selectCategory(item: { category_id?: string | number }) { + this.stalkerStore.setSelectedCategory(String(item.category_id ?? '*')); this.stalkerStore.setPage(0); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts index d697d0cfb..43b228999 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts @@ -63,13 +63,17 @@ export class StalkerMainContainerComponent { }); } - categoryClicked(item: { category_name: string; category_id: string }) { - this.stalkerStore.setSelectedCategory(item.category_id || '*'); + categoryClicked(item: { + category_name?: string; + category_id?: string | number; + }) { + const categoryId = String(item.category_id ?? '*'); + this.stalkerStore.setSelectedCategory(categoryId); this.currentLayout = 'category_content'; this.stalkerStore.setPage(0); this.stalkerStore.setSelectedItem(undefined); - this.router.navigate(['.', item.category_id], { + this.router.navigate(['.', categoryId], { relativeTo: this.route, }); } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 040cbed44..88259e6f1 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -1,6 +1,13 @@ import { inject, Injectable } from '@angular/core'; import { DatabaseService, PlaybackPositionService } from 'services'; -import { XtreamCategory } from 'shared-interfaces'; +import { + PlaybackPositionData, + PlaylistMeta, + XtreamCategory, + XtreamLiveStream, + XtreamSerieItem, + XtreamVodStream, +} from 'shared-interfaces'; import { CategoryType, StreamType, @@ -43,7 +50,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, - } as any); + } as unknown as PlaylistMeta); } async updatePlaylist( @@ -132,10 +139,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { } try { - const restoreData = JSON.parse(restoreDataStr); - const hiddenCategories = restoreData.hiddenCategories as - | { xtreamId: number; type: string }[] - | undefined; + const restoreData = JSON.parse(restoreDataStr) as { + hiddenCategories?: { xtreamId: number; type: string }[]; + }; + const hiddenCategories = restoreData.hiddenCategories; if (!hiddenCategories || hiddenCategories.length === 0) { return undefined; @@ -209,7 +216,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { // Save to cache with progress tracking await this.dbService.saveXtreamContent( playlistId, - remoteData as any[], + remoteData as + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[], type, onProgress ); @@ -221,7 +231,11 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async saveContent( playlistId: string, - streams: any[], + streams: + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[], type: 'live' | 'movie' | 'series', onProgress?: ProgressCallback ): Promise { @@ -309,7 +323,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { // Playback Position Operations // ========================================================================= - async savePlaybackPosition(playlistId: string, data: any): Promise { + async savePlaybackPosition( + playlistId: string, + data: PlaybackPositionData + ): Promise { await this.playbackService.savePlaybackPosition(playlistId, data); } @@ -317,7 +334,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode' - ): Promise { + ): Promise { return this.playbackService.getPlaybackPosition( playlistId, contentXtreamId, @@ -328,7 +345,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async getSeriesPlaybackPositions( playlistId: string, seriesXtreamId: number - ): Promise { + ): Promise { return this.playbackService.getSeriesPlaybackPositions( playlistId, seriesXtreamId @@ -338,14 +355,16 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async getRecentPlaybackPositions( playlistId: string, limit?: number - ): Promise { + ): Promise { return this.playbackService.getRecentPlaybackPositions( playlistId, limit ); } - async getAllPlaybackPositions(playlistId: string): Promise { + async getAllPlaybackPositions( + playlistId: string + ): Promise { return this.playbackService.getAllPlaybackPositions(playlistId); } @@ -368,7 +387,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { /** * No-op for Electron: DB-backed storage has no in-memory session cache to clear. */ - clearSessionCache(_playlistId: string): void { + clearSessionCache(playlistId: string): void { + void playlistId; // Electron uses the DB as its cache layer; no in-memory state to evict. } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts index 5686f3792..7f20fe0af 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts @@ -1,4 +1,4 @@ -import { EnvironmentProviders, inject, Provider } from '@angular/core'; +import { inject, Provider } from '@angular/core'; import { ElectronXtreamDataSource } from './electron-xtream-data-source'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; import { IXtreamDataSource, XTREAM_DATA_SOURCE } from './xtream-data-source.interface'; @@ -15,7 +15,7 @@ export { PwaXtreamDataSource } from './pwa-xtream-data-source'; */ export function xtreamDataSourceFactory(): IXtreamDataSource { // Check if we're in Electron environment - if (typeof window !== 'undefined' && (window as any).electron) { + if (typeof window !== 'undefined' && window.electron) { return inject(ElectronXtreamDataSource); } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index cffe2ee83..d5d811b8b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { + PlaybackPositionData, XtreamCategory, XtreamLiveStream, XtreamSerieItem, @@ -31,6 +32,26 @@ const STORAGE_KEYS = { PLAYBACK_POSITIONS: 'xtream-playback-positions', }; +interface XtreamCachedContentItem { + readonly added?: string; + readonly category_id?: string | number; + readonly id?: number; + readonly name?: string; + readonly poster_url?: string; + readonly series_id?: number; + readonly stream_display_name?: string; + readonly stream_id?: number; + readonly stream_icon?: string; + readonly title?: string; + readonly type?: string; + readonly viewed_at?: string; +} + +interface StoredRecentItem { + readonly id: number; + readonly viewedAt: string; +} + /** * PWA implementation of the Xtream data source. * Uses API-only strategy: always fetch from API, no database caching. @@ -43,7 +64,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // In-memory cache for the current session private categoryCache = new Map(); - private contentCache = new Map(); + private contentCache = new Map(); // ========================================================================= // Playlist Operations (localStorage) @@ -119,8 +140,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const cacheKey = `${playlistId}-${type}-categories`; // Check in-memory cache first - if (this.categoryCache.has(cacheKey)) { - return this.categoryCache.get(cacheKey)!; + const cachedCategories = this.categoryCache.get(cacheKey); + if (cachedCategories) { + return cachedCategories; } // Fetch from API @@ -139,6 +161,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, type: DbCategoryType ): Promise { + void playlistId; + void type; // PWA doesn't track hidden categories - return empty return []; } @@ -157,6 +181,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { categoryIds: number[], hidden: boolean ): Promise { + void categoryIds; + void hidden; // Category visibility is not supported in PWA mode this.logger.warn('Category visibility not supported in PWA mode'); } @@ -183,8 +209,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const cacheKey = `${playlistId}-${type}-content`; // Check in-memory cache first - if (this.contentCache.has(cacheKey)) { - return this.contentCache.get(cacheKey)!; + const cachedContent = this.contentCache.get(cacheKey); + if (cachedContent) { + return cachedContent as + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[]; } // Fetch from API @@ -206,7 +236,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async saveContent( playlistId: string, - streams: any[], + streams: + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[], type: 'live' | 'movie' | 'series', onProgress?: ProgressCallback ): Promise { @@ -229,16 +263,17 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, searchTerm: string, types: string[], - _excludeHidden?: boolean + excludeHidden?: boolean ): Promise { - const results: any[] = []; + void excludeHidden; + const results: XtreamCachedContentItem[] = []; const searchLower = searchTerm.toLowerCase(); for (const type of types) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; - const filtered = content.filter((item: any) => { + const filtered = content.filter((item) => { const title = item.name || item.title || item.stream_display_name || ''; return title.toLowerCase().includes(searchLower); @@ -259,12 +294,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const playlistFavorites = allFavorites[playlistId] || []; // Match favorites with cached content - const results: any[] = []; + const results: XtreamCachedContentItem[] = []; for (const type of ['live', 'movie', 'series']) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; - for (const item of content as any[]) { + for (const item of content) { const itemId = item.stream_id || item.series_id || item.id; if (playlistFavorites.includes(itemId)) { results.push(item); @@ -324,7 +359,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Playback Position Operations (localStorage) // ========================================================================= - async savePlaybackPosition(playlistId: string, data: any): Promise { + async savePlaybackPosition( + playlistId: string, + data: PlaybackPositionData + ): Promise { const allPositions = this.getPlaybackPositionsFromStorage(); if (!allPositions[playlistId]) { allPositions[playlistId] = []; @@ -332,7 +370,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Remove existing entry if present allPositions[playlistId] = allPositions[playlistId].filter( - (p: any) => + (p) => !( p.contentXtreamId === data.contentXtreamId && p.contentType === data.contentType @@ -352,13 +390,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode' - ): Promise { + ): Promise { const allPositions = this.getPlaybackPositionsFromStorage(); const playlistPositions = allPositions[playlistId] || []; return ( playlistPositions.find( - (p: any) => + (p) => p.contentXtreamId === contentXtreamId && p.contentType === contentType ) || null @@ -368,12 +406,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getSeriesPlaybackPositions( playlistId: string, seriesXtreamId: number - ): Promise { + ): Promise { const allPositions = this.getPlaybackPositionsFromStorage(); const playlistPositions = allPositions[playlistId] || []; return playlistPositions.filter( - (p: any) => + (p) => p.contentType === 'episode' && p.seriesXtreamId === seriesXtreamId ); @@ -382,13 +420,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getRecentPlaybackPositions( playlistId: string, limit?: number - ): Promise { + ): Promise { const allPositions = this.getPlaybackPositionsFromStorage(); const playlistPositions = allPositions[playlistId] || []; // Sort by updatedAt descending playlistPositions.sort( - (a: any, b: any) => + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); @@ -396,7 +434,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return limit ? playlistPositions.slice(0, limit) : playlistPositions; } - async getAllPlaybackPositions(playlistId: string): Promise { + async getAllPlaybackPositions( + playlistId: string + ): Promise { const allPositions = this.getPlaybackPositionsFromStorage(); return allPositions[playlistId] || []; } @@ -409,7 +449,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const allPositions = this.getPlaybackPositionsFromStorage(); if (allPositions[playlistId]) { allPositions[playlistId] = allPositions[playlistId].filter( - (p: any) => + (p) => !( p.contentXtreamId === contentXtreamId && p.contentType === contentType @@ -419,7 +459,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } } - private getPlaybackPositionsFromStorage(): Record { + private getPlaybackPositionsFromStorage(): Record< + string, + PlaybackPositionData[] + > { try { const data = localStorage.getItem(STORAGE_KEYS.PLAYBACK_POSITIONS); return data ? JSON.parse(data) : {}; @@ -429,7 +472,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } private savePlaybackPositionsToStorage( - positions: Record + positions: Record ): void { localStorage.setItem( STORAGE_KEYS.PLAYBACK_POSITIONS, @@ -452,15 +495,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const playlistRecent = allRecent[playlistId] || []; // Match recent items with cached content - const results: any[] = []; + const results: (XtreamCachedContentItem & { viewed_at: string })[] = []; for (const type of ['live', 'movie', 'series']) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; - for (const item of content as any[]) { + for (const item of content) { const itemId = item.stream_id || item.series_id || item.id; const recentEntry = playlistRecent.find( - (r: any) => r.id === itemId + (r) => r.id === itemId ); if (recentEntry) { results.push({ @@ -489,7 +532,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Remove existing entry if present allRecent[playlistId] = allRecent[playlistId].filter( - (r: any) => r.id !== contentId + (r) => r.id !== contentId ); // Add new entry at the beginning @@ -511,7 +554,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const allRecent = this.getRecentItemsFromStorage(); if (allRecent[playlistId]) { allRecent[playlistId] = allRecent[playlistId].filter( - (r: any) => r.id !== contentId + (r) => r.id !== contentId ); } this.saveRecentItemsToStorage(allRecent); @@ -523,7 +566,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getRecentItemsFromStorage(): Record< string, - { id: number; viewedAt: string }[] + StoredRecentItem[] > { try { const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS); @@ -534,7 +577,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } private saveRecentItemsToStorage( - recentItems: Record + recentItems: Record ): void { localStorage.setItem( STORAGE_KEYS.RECENT_ITEMS, @@ -560,7 +603,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; - const found = (content as any[]).find((item) => { + const found = content.find((item) => { const itemXtreamId = item.stream_id || item.series_id || item.id; return itemXtreamId === xtreamId; @@ -588,7 +631,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const favoritedXtreamIds = favorites[playlistId] || []; const recentlyViewedXtreamIds = (recentItems[playlistId] || []).map( - (r: any) => ({ + (r) => ({ xtreamId: r.id, viewedAt: r.viewedAt, }) diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 3a6fd7992..32770b627 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -227,7 +227,11 @@ export interface IXtreamDataSource { */ saveContent( playlistId: string, - streams: any[], + streams: + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[], type: 'live' | 'movie' | 'series', onProgress?: ProgressCallback ): Promise; diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts index d051a8f87..2a1f58010 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts @@ -84,7 +84,10 @@ export class EpgQueueService implements OnDestroy { continue; } - const streamId = this.queue.shift()!; + const streamId = this.queue.shift(); + if (streamId == null) { + continue; + } // Drop stale entries that are no longer visible if (!this.visibleSet.has(streamId)) continue; diff --git a/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts b/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts index f591f6b58..6913a0d50 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts @@ -1,7 +1,7 @@ export interface FavoriteItem { content_id: number; playlist_id: string; - type: 'live' | 'vod' | 'series'; + type: 'live' | 'movie' | 'vod' | 'series'; title: string; stream_icon?: string; poster_url?: string; diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts index 1072d677c..f4ec45697 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts @@ -33,7 +33,7 @@ export class FavoritesService { items.map((item) => ({ content_id: item.id, playlist_id: playlistId, - type: item.type as 'live' | 'vod' | 'series', + type: item.type as FavoriteItem['type'], title: item.title, poster_url: item.poster_url, added_at: item.added_at, diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index 5274e4562..cb5d1a1f6 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -261,16 +261,23 @@ export class XtreamApiService { /** * Send request via IPC to avoid CORS issues */ - private async sendRequest(url: string, params: Record): Promise { + private async sendRequest( + url: string, + params: Record + ): Promise { const serializedParams: Record = {}; Object.entries(params).forEach(([key, value]) => { serializedParams[key] = String(value); }); - const response = await this.dataService.sendIpcEvent(XTREAM_REQUEST, { + const response = (await this.dataService.sendIpcEvent(XTREAM_REQUEST, { url, params: serializedParams, - }); + })) 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 @@ -279,6 +286,6 @@ export class XtreamApiService { throw new Error(response?.message ?? 'Request failed'); } - return response?.payload; + return response?.payload as TResponse; } } diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index e54ea65c6..a617a8f53 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -26,6 +26,10 @@ export interface LiveStreamItem { [key: string]: unknown; } +type XtreamVodStreamLike = XtreamVodDetails & { + readonly stream_id?: number; +}; + /** * Service for constructing Xtream stream URLs. * Handles URL construction for live streams, VOD, and series episodes. @@ -56,8 +60,9 @@ export class XtreamUrlService { credentials: XtreamCredentials, vodItem: XtreamVodDetails ): string { + const vod = vodItem as XtreamVodStreamLike; const streamId = - vodItem.movie_data.stream_id ?? (vodItem as any).stream_id; + vod.movie_data.stream_id ?? vod.stream_id; const extension = vodItem.movie_data.container_extension; return `${credentials.serverUrl}/movie/${credentials.username}/${credentials.password}/${streamId}.${extension}`; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 5867ac103..a898d63c6 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -65,6 +65,15 @@ const initialContentState: ContentState = { */ export function withContent() { const logger = createLogger('withContent'); + type ParentPortalStoreLike = { + currentPlaylist?: () => { + password: string; + serverUrl: string; + username: string; + } | null; + playlistId?: () => string | null; + }; + return signalStoreFeature( withState(initialContentState), @@ -129,7 +138,7 @@ export function withContent() { credentials: XtreamCredentials; } | null => { // Access parent store state (currentPlaylist is from withPortal) - const storeAny = store as any; + const storeAny = store as ParentPortalStoreLike; const playlist = storeAny.currentPlaylist?.(); const playlistId = storeAny.playlistId?.(); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 70e5a9e65..b86d0534b 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -36,6 +36,17 @@ const initialEpgState: EpgState = { */ export function withEpg() { const logger = createLogger('withEpg'); + type ParentSelectionStoreLike = { + currentPlaylist?: () => { + password: string; + serverUrl: string; + username: string; + } | null; + selectedItem?: () => { + xtream_id?: number | null; + } | null; + }; + return signalStoreFeature( withState(initialEpgState), @@ -46,7 +57,7 @@ export function withEpg() { * Helper to get credentials from parent store */ const getCredentialsFromStore = (): XtreamCredentials | null => { - const storeAny = store as any; + const storeAny = store as ParentSelectionStoreLike; const playlist = storeAny.currentPlaylist?.(); if (!playlist) { @@ -72,7 +83,7 @@ export function withEpg() { } // Access selected item from parent store (from withSelection) - const storeAny = store as any; + const storeAny = store as ParentSelectionStoreLike; const selectedItem = storeAny.selectedItem?.(); if (!selectedItem?.xtream_id) { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts index 7a103218f..9b9db04ff 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts @@ -342,7 +342,7 @@ export function withPlaybackPositions() { onInit() { if (window.electron?.onPlaybackPositionUpdate) { unsubscribe = window.electron.onPlaybackPositionUpdate( - (data: any) => { + (data: PlaybackPositionData) => { if (data.playlistId) { store.savePosition(data.playlistId, data); } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts index 191c9bdcc..30d909923 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts @@ -27,6 +27,23 @@ const initialPlayerState: PlayerState = { localStorage.getItem('hideExternalInfoDialog') === 'true', }; +interface XtreamPlayableItem { + readonly xtream_id: number; +} + +type XtreamPlaylistHeadersLike = XtreamCredentials & { + readonly origin?: string; + readonly referrer?: string; + readonly userAgent?: string; +}; + +type ParentPlayerStoreLike = { + currentPlaylist?: () => XtreamPlaylistHeadersLike | null; + loadEpg?: () => void; + selectedContentType?: () => 'live' | 'vod' | 'series' | undefined; + setSelectedItem?: (item: XtreamPlayableItem) => void; +}; + /** * Player feature store for managing stream playback. * Handles: @@ -46,7 +63,7 @@ export function withPlayer() { * Helper to get credentials from parent store */ const getCredentialsFromStore = (): XtreamCredentials | null => { - const storeAny = store as any; + const storeAny = store as ParentPlayerStoreLike; const playlist = storeAny.currentPlaylist?.(); if (!playlist) { @@ -63,8 +80,8 @@ export function withPlayer() { /** * Helper to get playlist with headers from parent store */ - const getPlaylistFromStore = () => { - const storeAny = store as any; + const getPlaylistFromStore = (): XtreamPlaylistHeadersLike | null => { + const storeAny = store as ParentPlayerStoreLike; return storeAny.currentPlaylist?.(); }; @@ -72,7 +89,7 @@ export function withPlayer() { /** * Construct and return live stream URL */ - constructStreamUrl(item: any): string { + constructStreamUrl(item: XtreamPlayableItem): string { const credentials = getCredentialsFromStore(); if (!credentials) { return ''; @@ -84,7 +101,7 @@ export function withPlayer() { ); // Set selected item in parent store and load EPG - const storeAny = store as any; + const storeAny = store as ParentPlayerStoreLike; if (storeAny.setSelectedItem) { storeAny.setSelectedItem(item); } @@ -138,10 +155,10 @@ export function withPlayer() { title: string, thumbnail: string | null = null, startTime?: number, - contentInfo?: any + contentInfo?: unknown ): void { const playlist = getPlaylistFromStore(); - const storeAny = store as any; + const storeAny = store as ParentPlayerStoreLike; const contentType = storeAny.selectedContentType?.() || 'vod'; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index 3c25d0d4a..c3eaa2325 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -60,6 +60,10 @@ const initialSearchState: SearchState = { */ export function withSearch() { const logger = createLogger('withSearch'); + type ParentSearchStoreLike = { + playlistId?: () => string | null; + }; + return signalStoreFeature( withState(initialSearchState), @@ -76,7 +80,7 @@ export function withSearch() { excludeHidden?: boolean ): Promise { // Access parent store's playlistId (from withPortal) - const storeAny = store as any; + const storeAny = store as ParentSearchStoreLike; const playlistId = storeAny.playlistId?.(); if (!playlistId || !searchTerm.trim()) { @@ -115,7 +119,7 @@ export function withSearch() { */ setGlobalSearchResults(results: GlobalSearchResult[]): void { patchState(store, { - searchResults: results as any, + searchResults: results, globalSearchResults: results, isSearching: false, }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index b4f086b8c..4470b4501 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -29,7 +29,7 @@ export type XtreamCategorySortMode = export interface SelectionState { selectedContentType: ContentType; selectedCategoryId: number | null; - selectedItem: any | null; + selectedItem: XtreamSelectionItem | null; page: number; limit: number; contentSortMode: XtreamCategorySortMode; @@ -53,6 +53,63 @@ const initialSelectionState: SelectionState = { detailsError: null, }; +interface XtreamSelectionCategory { + readonly [key: string]: unknown; + readonly category_name?: string; + readonly category_id?: string | number; + readonly id?: string | number; + readonly name?: string; + readonly type?: ContentType; +} + +interface XtreamSelectionItem { + readonly [key: string]: unknown; + readonly added?: string; + readonly category_id?: string | number; + readonly episodes?: unknown; + readonly id?: string | number; + readonly info?: { + readonly actors?: string; + readonly backdrop_path?: string[]; + readonly cast?: string; + readonly country?: string; + readonly cover?: string; + readonly description?: string; + readonly director?: string; + readonly duration?: string; + readonly episode_run_time?: number | string; + readonly genre?: string; + readonly movie_image?: string; + readonly name?: string; + readonly plot?: string; + readonly rating?: number | string; + readonly rating_imdb?: string; + readonly rating_kinopoisk?: string; + readonly releaseDate?: string; + readonly releasedate?: string; + readonly youtube_trailer?: string; + }; + readonly last_modified?: string; + readonly movie_data?: { + readonly name?: string; + }; + readonly name?: string; + readonly series_id?: string | number; + readonly stream_id?: string | number; + readonly title?: string; + readonly xtream_id?: number; +} + +type ParentSelectionStoreLike = { + isLoadingContent?: () => boolean; + liveCategories?: () => XtreamSelectionCategory[]; + liveStreams?: () => XtreamSelectionItem[]; + serialCategories?: () => XtreamSelectionCategory[]; + serialStreams?: () => XtreamSelectionItem[]; + vodCategories?: () => XtreamSelectionCategory[]; + vodStreams?: () => XtreamSelectionItem[]; +}; + /** * Selection feature store for managing UI selection and pagination. * Handles: @@ -67,7 +124,7 @@ export function withSelection() { withComputed((store) => { const getItemDate = ( - item: any, + item: XtreamSelectionItem, categoryType: ContentType ): number => { const value = @@ -78,11 +135,11 @@ export function withSelection() { }; const sortByMode = ( - items: any[], + items: XtreamSelectionItem[], sortMode: XtreamCategorySortMode, categoryType: ContentType - ): any[] => { - return [...items].sort((a: any, b: any) => { + ): XtreamSelectionItem[] => { + return [...items].sort((a, b) => { if (sortMode === 'date-desc') { return ( getItemDate(b, categoryType) - @@ -104,15 +161,15 @@ export function withSelection() { }; const filterBySearchTerm = ( - items: any[], + items: XtreamSelectionItem[], searchTerm: string - ): any[] => { + ): XtreamSelectionItem[] => { const normalized = searchTerm.trim().toLocaleLowerCase(); if (!normalized) { return items; } - return items.filter((item: any) => { + return items.filter((item) => { const title = (item.title ?? item.name ?? '').toString(); return title.toLocaleLowerCase().includes(normalized); }); @@ -121,7 +178,7 @@ export function withSelection() { // Memoized sorted content - only recalculates when content/type changes const sortedContent = computed(() => { const categoryType = store.selectedContentType(); - const storeAny = store as any; + const storeAny = store as ParentSelectionStoreLike; const content = categoryType === 'live' ? storeAny.liveStreams?.() || [] @@ -137,7 +194,9 @@ export function withSelection() { // Each computed only recomputes when ITS streams array changes — // switching content tabs no longer triggers an O(n) full scan. // --------------------------------------------------------------------------- - const buildCountMap = (streams: any[]): Map => { + const buildCountMap = ( + streams: XtreamSelectionItem[] + ): Map => { const countMap = new Map(); for (const item of streams) { const catId = Number(item.category_id); @@ -149,13 +208,19 @@ export function withSelection() { }; const liveItemCounts = computed(() => - buildCountMap((store as any).liveStreams?.() || []) + buildCountMap( + (store as ParentSelectionStoreLike).liveStreams?.() || [] + ) ); const vodItemCounts = computed(() => - buildCountMap((store as any).vodStreams?.() || []) + buildCountMap( + (store as ParentSelectionStoreLike).vodStreams?.() || [] + ) ); const seriesItemCounts = computed(() => - buildCountMap((store as any).serialStreams?.() || []) + buildCountMap( + (store as ParentSelectionStoreLike).serialStreams?.() || [] + ) ); // --------------------------------------------------------------------------- @@ -169,7 +234,7 @@ export function withSelection() { const sortMode = store.contentSortMode(); const searchTerm = store.categorySearchTerm(); - const storeAny = store as any; + const storeAny = store as ParentSelectionStoreLike; const content = categoryType === 'live' ? storeAny.liveStreams?.() || [] @@ -182,7 +247,7 @@ export function withSelection() { } let filtered = content.filter( - (item: any) => Number(item.category_id) === categoryId + (item) => Number(item.category_id) === categoryId ); if (categoryType === 'vod' || categoryType === 'series') { filtered = filterBySearchTerm(filtered, searchTerm); @@ -206,15 +271,15 @@ export function withSelection() { } // Access parent store categories (from withContent) - const storeAny = store as any; - const allCategories = [ + const storeAny = store as ParentSelectionStoreLike; + const allCategories: XtreamSelectionCategory[] = [ ...(storeAny.vodCategories?.() || []), ...(storeAny.liveCategories?.() || []), ...(storeAny.serialCategories?.() || []), ]; return allCategories.find( - (c: any) => + (c) => c.id === categoryId || c.category_id === String(categoryId) ); @@ -230,7 +295,7 @@ export function withSelection() { if (!selectedItem) return null; // Access parent store content (from withContent) - const storeAny = store as any; + const storeAny = store as ParentSelectionStoreLike; const content = categoryType === 'live' ? storeAny.liveStreams?.() || [] @@ -239,7 +304,7 @@ export function withSelection() { : storeAny.serialStreams?.() || []; return content.find( - (item: any) => + (item) => item.stream_id === selectedItem.stream_id || item.id === selectedItem.id || item.series_id === selectedItem.series_id @@ -281,7 +346,7 @@ export function withSelection() { */ isPaginatedContentLoading: computed(() => { // Access parent store loading state (from withContent) - const storeAny = store as any; + const storeAny = store as ParentSelectionStoreLike; return storeAny.isLoadingContent?.() || false; }), @@ -306,7 +371,7 @@ export function withSelection() { const type = store.selectedContentType(); // Access parent store categories (from withContent) - const storeAny = store as any; + const storeAny = store as ParentSelectionStoreLike; return type === 'live' ? storeAny.liveCategories?.() || [] : type === 'vod' @@ -351,7 +416,7 @@ export function withSelection() { /** * Set the selected item */ - setSelectedItem(item: any): void { + setSelectedItem(item: XtreamSelectionItem | null): void { patchState(store, { selectedItem: item }); }, diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index 5b664956b..cfd10a932 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -77,7 +77,15 @@ export const XtreamStore = signalStore( const ngrxStore = inject(Store); const dataSource = inject(XTREAM_DATA_SOURCE); const logger = createLogger('XtreamStore'); - const searchContent = (store as any).searchContent as ( + const searchContent = ( + store as { + searchContent: ( + term: string, + types: string[], + excludeHidden?: boolean + ) => Promise; + } + ).searchContent as ( term: string, types: string[], excludeHidden?: boolean diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index 4ed45561c..fc1094c2d 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -14,6 +14,7 @@ import { extractStalkerItemTitle, extractStalkerItemType, normalizeStalkerDate, + Playlist, PortalRecentItem, } from 'shared-interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; @@ -146,20 +147,18 @@ export const withRecentItems = function () { await dbService.getGlobalRecentlyViewed(); const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() - )) as any[]; + )) as Playlist[]; const stalkerItems: RecentlyViewedItem[] = playlists - .filter((playlist: any) => - Boolean(playlist.macAddress) - ) + .filter((playlist) => Boolean(playlist.macAddress)) .reduce( - (acc: RecentlyViewedItem[], playlist: any) => { + (acc: RecentlyViewedItem[], playlist) => { const recent = Array.isArray( playlist.recentlyViewed ) ? playlist.recentlyViewed : []; const mapped = recent.map( - (rawItem: any, index: number) => { + (rawItem, index: number) => { const item = (rawItem ?? {}) as Record; const id = extractStalkerItemId( @@ -245,13 +244,13 @@ export const withRecentItems = function () { await dbService.clearGlobalRecentlyViewed(); const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() - )) as any[]; + )) as Playlist[]; await Promise.all( playlists - .filter((playlist: any) => + .filter((playlist) => Boolean(playlist.macAddress) ) - .map((playlist: any) => + .map((playlist) => firstValueFrom( playlistsService.clearPortalRecentlyViewed( playlist._id diff --git a/libs/portal/xtream/data-access/src/lib/xtream-state.ts b/libs/portal/xtream/data-access/src/lib/xtream-state.ts index 4d380d680..4f269cffd 100644 --- a/libs/portal/xtream/data-access/src/lib/xtream-state.ts +++ b/libs/portal/xtream/data-access/src/lib/xtream-state.ts @@ -6,6 +6,10 @@ import { XtreamSerieItem, XtreamVodStream, } from 'shared-interfaces'; +import { + XtreamContentItem, + XtreamPlaylistData, +} from './data-sources/xtream-data-source.interface'; export type ContentType = 'live' | 'vod' | 'series'; @@ -33,12 +37,12 @@ export interface XtreamState { page: number; limit: number; selectedCategoryId: number | null; - searchResults: any[]; + searchResults: XtreamContentItem[]; selectedContentType: ContentType; - selectedItem: any | null; + selectedItem: unknown | null; importCount: number; itemsToImport: number; - currentPlaylist: any | null; + currentPlaylist: XtreamPlaylistData | null; epgItems: EpgItem[]; hideExternalInfoDialog: boolean; portalStatus: PortalStatusType; diff --git a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts index 48b202b39..e3dee940f 100644 --- a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts +++ b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts @@ -17,6 +17,7 @@ import { selectActivePlaylist } from 'm3u-state'; import { BehaviorSubject, switchMap } from 'rxjs'; import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui'; import { + FavoriteLayoutItem, PortalCollectionMode, PortalCollectionShellComponent, PortalCollectionShellLayout, @@ -45,6 +46,35 @@ type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; const XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY = 'xtream-favorites-live-channel-sort-mode'; +interface XtreamFavoriteLiveItem { + readonly poster_url?: string; + readonly stream_icon?: string; + readonly title?: string; + readonly xtream_id: number; +} + +interface XtreamFavoriteRouteItem extends FavoriteLayoutItem { + readonly content_id: number; + readonly category_id: string | number; + readonly playlist_id: string; + readonly title: string; + readonly type: FavoriteItem['type']; + readonly xtream_id: number; +} + +function isXtreamFavoriteRouteItem( + item: FavoriteLayoutItem +): item is XtreamFavoriteRouteItem { + return ( + typeof item.content_id === 'number' && + item.category_id != null && + typeof item.playlist_id === 'string' && + typeof item.title === 'string' && + typeof item.xtream_id === 'number' && + typeof item.type === 'string' + ); +} + @Component({ selector: 'app-favorites', imports: [ @@ -105,7 +135,7 @@ export class FavoritesComponent implements OnInit { ctx: this.favoritesCtx, categories: this.categories, }); - readonly selectedLiveItem = signal(null); + readonly selectedLiveItem = signal(null); readonly liveStreamUrl = signal(''); readonly mode = computed(() => this.isLiveCategory() ? 'live' : 'grid' @@ -202,7 +232,9 @@ export class FavoritesComponent implements OnInit { .subscribe((items) => { this.allItems.set(items); this.movies.set( - items.filter((item) => (item as any).type === 'movie') + items.filter( + (item) => item.type === 'movie' || item.type === 'vod' + ) ); this.live.set(items.filter((item) => item.type === 'live')); this.series.set(items.filter((item) => item.type === 'series')); @@ -213,7 +245,11 @@ export class FavoritesComponent implements OnInit { this.collectionContext.setCategoryId(categoryId); } - async removeFromFavorites(item: any) { + async removeFromFavorites(item: FavoriteLayoutItem) { + if (!isXtreamFavoriteRouteItem(item)) { + return; + } + await this.favoritesService.removeFromFavorites( item.content_id, item.playlist_id @@ -221,7 +257,11 @@ export class FavoritesComponent implements OnInit { this.favoritesRefresh$.next(); } - openItem(item: any) { + openItem(item: FavoriteLayoutItem) { + if (!isXtreamFavoriteRouteItem(item)) { + return; + } + const type = item.type === 'movie' ? 'vod' : item.type; this.xtreamStore.setSelectedContentType(type); if (type === 'live') { @@ -259,7 +299,7 @@ export class FavoritesComponent implements OnInit { } } - selectLiveItem(item: FavoriteItem) { + selectLiveItem(item: XtreamFavoriteLiveItem) { this.xtreamStore.setSelectedContentType('live'); this.selectedLiveItem.set(item); const streamUrl = this.xtreamStore.constructStreamUrl(item); @@ -271,7 +311,7 @@ export class FavoritesComponent implements OnInit { this.xtreamStore.openPlayer( streamUrl, - item.title, + item.title ?? '', item.poster_url || item.stream_icon || null ); } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index d5a6448d2..547642226 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -31,9 +31,13 @@ import { getChannelItemByNumber, isWorkspaceLayoutRoute, } from '@iptvnator/portal/shared/util'; -import { FavoritesService, XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { XtreamCategory } from 'shared-interfaces'; +import { + FavoriteItem, + FavoritesService, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; +import { EpgItem } from 'shared-interfaces'; import { CategoryManagementDialogComponent, CategoryManagementDialogData, @@ -43,6 +47,21 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; +interface XtreamCategoryLike { + readonly category_id?: string | number; + readonly category_name?: string; + readonly id?: string | number; + readonly name?: string; +} + +interface XtreamLiveChannelItem { + readonly name?: string; + readonly poster_url?: string; + readonly stream_icon?: string; + readonly title?: string; + readonly xtream_id: number; +} + @Component({ selector: 'app-live-stream-layout', templateUrl: './live-stream-layout.component.html', @@ -96,7 +115,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const categories = this.categories(); const category = categories?.find( - (c: any) => (c.category_id ?? c.id) === categoryId + (c) => (c.category_id ?? c.id) === categoryId ); const count = this.categoryItemCounts()?.get(categoryId) ?? 0; @@ -117,7 +136,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); - streamUrl: string; + streamUrl = ''; favorites = new Map(); constructor() { @@ -136,8 +155,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } - const channels = - this.xtreamStore.selectItemsFromSelectedCategory() as any[]; + const channels = this.getVisibleChannels(); if (!Array.isArray(channels) || channels.length === 0) { return; } @@ -162,8 +180,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const selectedContentType = this.xtreamStore.selectedContentType(); const selectedItem = this.xtreamStore.selectedItem(); - const channels = - this.xtreamStore.selectItemsFromSelectedCategory() as any[]; + const channels = this.getVisibleChannels(); const epgItems = this.xtreamStore.epgItems(); if (selectedContentType !== 'live' || !selectedItem?.xtream_id) { @@ -179,7 +196,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { (item) => Number(item.xtream_id) === Number(selectedItem.xtream_id) ); - const currentProgram = epgItems?.[0]; + const currentProgram: EpgItem | undefined = epgItems?.[0]; window.electron.updateRemoteControlStatus({ portal: 'xtream', @@ -187,8 +204,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { channelName: selectedItem.title ?? selectedItem.name, channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined, epgTitle: currentProgram?.title, - epgStart: (currentProgram as any)?.start, - epgEnd: (currentProgram as any)?.end, + epgStart: currentProgram?.start, + epgEnd: currentProgram?.stop ?? currentProgram?.end, supportsVolume: false, }); }); @@ -233,7 +250,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { .getFavorites(playlist.id) .subscribe((favorites) => { // Map using content.id instead of xtream_id - favorites.forEach((fav: any) => { + favorites.forEach((fav: FavoriteItem) => { this.favorites.set(fav.xtream_id, true); }); }); @@ -244,22 +261,29 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.xtreamStore.setSelectedCategory(Number(categoryId)); } - playLive(item: any) { + playLive(item: XtreamLiveChannelItem) { const streamUrl = this.xtreamStore.constructStreamUrl(item); this.streamUrl = streamUrl; if (this.usesEmbeddedPlayer()) { return; } - this.xtreamStore.openPlayer(streamUrl, item.title, item.poster_url); + this.xtreamStore.openPlayer( + streamUrl, + item.title ?? item.name ?? '', + item.poster_url ?? item.stream_icon ?? null + ); } - selectCategory(category: XtreamCategory) { + selectCategory(category: XtreamCategoryLike) { const el = this.categoryView()?.nativeElement; if (el) { this.categoryScrollTop = el.scrollTop; } - const categoryId = (category as any).category_id ?? category.id; - this.xtreamStore.setSelectedCategory(categoryId); + const categoryId = category.category_id ?? category.id; + if (categoryId == null) { + return; + } + this.xtreamStore.setSelectedCategory(Number(categoryId)); } backToCategories() { @@ -316,8 +340,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } - const channels = - this.xtreamStore.selectItemsFromSelectedCategory() as any[]; + const channels = this.getVisibleChannels(); const nextItem = getAdjacentChannelItem( channels, activeItem.xtream_id, @@ -344,8 +367,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } - const channels = - this.xtreamStore.selectItemsFromSelectedCategory() as any[]; + const channels = this.getVisibleChannels(); const channel = getChannelItemByNumber(channels, command.number); if (!channel) { return; @@ -354,6 +376,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.playLive(channel); } + private getVisibleChannels(): XtreamLiveChannelItem[] { + return this.xtreamStore.selectItemsFromSelectedCategory() as + XtreamLiveChannelItem[]; + } + private clearAutoOpenHistoryState(): void { try { const state = (window.history.state ?? {}) as Record< diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 3ac10e531..eda772d7d 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -36,6 +36,21 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; +export interface XtreamChannelListItem { + readonly category_id?: string | number; + readonly id?: string | number; + readonly name?: string; + readonly poster_url?: string; + readonly stream_icon?: string; + readonly title?: string; + readonly xtream_id: number; +} + +interface XtreamCategoryLike { + readonly category_id?: string | number; + readonly id?: string | number; +} + @Component({ selector: 'app-portal-channels-list', templateUrl: './portal-channels-list.component.html', @@ -50,9 +65,9 @@ type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; ], }) export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { - readonly playClicked = output(); + readonly playClicked = output(); readonly sortMode = input('server'); - readonly channelsOverride = input(null); + readonly channelsOverride = input(null); readonly xtreamStore = inject(XtreamStore); private readonly favoritesService = inject(FavoritesService); @@ -64,7 +79,8 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return override; } - return this.xtreamStore.selectItemsFromSelectedCategory(); + return this.xtreamStore.selectItemsFromSelectedCategory() as + XtreamChannelListItem[]; }); readonly sortedChannels = computed(() => { const mode = this.sortMode(); @@ -78,7 +94,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { sensitivity: 'base', }); - return [...channels].sort((a: any, b: any) => { + return [...channels].sort((a, b) => { const titleA = a.title ?? a.name ?? ''; const titleB = b.title ?? b.name ?? ''; const result = collator.compare(titleA, titleB); @@ -113,7 +129,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { }); } - trackBy(_index: number, item: XtreamItem) { + trackBy(_index: number, item: XtreamChannelListItem | XtreamItem) { return item.xtream_id; } @@ -127,7 +143,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { this.favoritesService .getFavorites(playlist.id) .subscribe((favorites) => { - favorites.forEach((fav: any) => { + favorites.forEach((fav) => { this.favorites.set(fav.xtream_id, true); }); }); @@ -163,7 +179,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { } } - private loadEpgForVisibleChannels(channels: any[]): void { + private loadEpgForVisibleChannels(channels: XtreamChannelListItem[]): void { const playlist = this.xtreamStore.currentPlaylist(); if (!playlist) return; @@ -222,13 +238,13 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { this.currentProgramsProgress.delete(streamId); } - isSelected(item: XtreamCategory): boolean { + isSelected(item: XtreamCategory | XtreamCategoryLike): boolean { const selectedCategory = this.xtreamStore.selectedCategoryId(); - const itemId = Number((item as any).category_id || item.id); + const itemId = Number(item.category_id ?? item.id); return selectedCategory !== null && selectedCategory === itemId; } - toggleFavorite(event: Event, item: any) { + toggleFavorite(event: Event, item: XtreamChannelListItem) { event.stopPropagation(); this.xtreamStore .toggleFavorite( diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts index 2051820f6..4716a5ae1 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts @@ -5,6 +5,21 @@ import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { ContentType } from '@iptvnator/portal/xtream/data-access'; +interface RecentlyAddedItem { + readonly added?: string; + readonly category_id: string | number; + readonly cover?: string; + readonly id?: number; + readonly last_modified?: string; + readonly name?: string; + readonly poster_url?: string; + readonly series_id?: number; + readonly stream_id?: number; + readonly stream_icon?: string; + readonly title?: string; + readonly xtream_id?: number; +} + @Component({ selector: 'app-recently-added', templateUrl: './recently-added.component.html', @@ -17,17 +32,27 @@ export class RecentlyAddedComponent { private readonly activatedRoute = inject(ActivatedRoute); readonly recentlyAddedLive = computed(() => - this.getRecentlyAdded(this.xtreamStore.liveStreams()) + this.getRecentlyAdded( + this.xtreamStore.liveStreams() as RecentlyAddedItem[] + ) ); readonly recentlyAddedVod = computed(() => - this.getRecentlyAdded(this.xtreamStore.vodStreams()) + this.getRecentlyAdded( + this.xtreamStore.vodStreams() as RecentlyAddedItem[] + ) ); readonly recentlyAddedSeries = computed(() => - this.getRecentlyAdded(this.xtreamStore.serialStreams(), true) + this.getRecentlyAdded( + this.xtreamStore.serialStreams() as RecentlyAddedItem[], + true + ) ); readonly selectedContentType = this.xtreamStore.selectedContentType; - private getRecentlyAdded(items: any[], isSeries = false) { + private getRecentlyAdded( + items: T[], + isSeries = false + ): T[] { return [...items] .sort((a, b) => { const dateA = @@ -39,7 +64,7 @@ export class RecentlyAddedComponent { .slice(0, 20); } - getDate(item: any): number { + getDate(item: RecentlyAddedItem): number { const timestamp = item.added || item.last_modified; return parseInt(timestamp) * 1000; } @@ -48,7 +73,7 @@ export class RecentlyAddedComponent { return this.selectedContentType() === type; } - openItem(item: any, type: ContentType) { + openItem(item: RecentlyAddedItem, type: ContentType) { this.xtreamStore.setSelectedContentType(type); if (type === 'live') { diff --git a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts index a7840ab05..358d5ad18 100644 --- a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts @@ -20,7 +20,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import groupBy from 'lodash/groupBy'; import { firstValueFrom } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; -import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; +import { ContentCardComponent, FavoriteLayoutItem } from '@iptvnator/portal/shared/ui'; import { PortalCollectionLiveShellComponent, } from '@iptvnator/portal/shared/ui'; @@ -64,6 +64,38 @@ type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; const XTREAM_RECENT_LIVE_SORT_STORAGE_KEY = 'xtream-recent-live-channel-sort-mode'; +type XtreamRecentContentType = 'live' | 'movie' | 'series'; + +interface XtreamRecentViewItem extends FavoriteLayoutItem { + readonly id: string | number; + readonly playlist_id: string; + readonly title: string; + readonly type: XtreamRecentContentType; + readonly xtream_id: string | number; +} + +interface XtreamRecentLiveItem { + readonly id?: string | number; + readonly poster_url?: string; + readonly stream_icon?: string; + readonly title?: string; + readonly xtream_id: number; +} + +function isXtreamRecentViewItem( + item: FavoriteLayoutItem +): item is XtreamRecentViewItem { + return ( + (typeof item.id === 'number' || typeof item.id === 'string') && + typeof item.playlist_id === 'string' && + typeof item.title === 'string' && + typeof item.xtream_id !== 'undefined' && + (item.type === 'live' || + item.type === 'movie' || + item.type === 'series') + ); +} + @Component({ selector: 'app-recently-viewed', imports: [ @@ -139,20 +171,16 @@ export class RecentlyViewedComponent { } })(); readonly typeFilters = computed(() => this._typeFilters()); - readonly recentItems = computed(() => - this.isGlobal + readonly recentItems = computed(() => + (this.isGlobal ? this.xtreamStore.globalRecentItems() - : this.xtreamStore.recentItems() + : this.xtreamStore.recentItems()) as XtreamRecentViewItem[] ); readonly categories = computed(() => { const items = this.recentItems(); - const movies = items.filter( - (item: any) => item?.type === 'movie' - ).length; - const live = items.filter((item: any) => item?.type === 'live').length; - const series = items.filter( - (item: any) => item?.type === 'series' - ).length; + const movies = items.filter((item) => item.type === 'movie').length; + const live = items.filter((item) => item.type === 'live').length; + const series = items.filter((item) => item.type === 'series').length; return buildStandardCollectionCategories({ labels: XTREAM_COLLECTION_LABELS, @@ -170,7 +198,7 @@ export class RecentlyViewedComponent { categories: this.categories, enabled: () => !this.isGlobal, }); - readonly selectedLiveItem = signal(null); + readonly selectedLiveItem = signal(null); readonly liveStreamUrl = signal(''); readonly nonGlobalMode = computed(() => this.isLiveCategory() ? 'live' : 'grid' @@ -188,16 +216,16 @@ export class RecentlyViewedComponent { ? this.workspaceSearchTerm() : this.recentSearchTerm().trim().toLowerCase(); const typeFilters = this.typeFilters(); - const items = this.recentItems().filter((item: any) => { - if (item?.type === 'live') return typeFilters.live; - if (item?.type === 'movie') return typeFilters.movie; - if (item?.type === 'series') return typeFilters.series; + const items = this.recentItems().filter((item) => { + if (item.type === 'live') return typeFilters.live; + if (item.type === 'movie') return typeFilters.movie; + if (item.type === 'series') return typeFilters.series; return true; }); if (!term) return items; - return items.filter((item: any) => - `${item?.title ?? ''} ${item?.playlist_name ?? ''}` + return items.filter((item) => + `${item.title} ${item.playlist_name ?? ''}` .toLowerCase() .includes(term) ); @@ -218,32 +246,39 @@ export class RecentlyViewedComponent { selectedCategoryId: this.selectedCategoryId(), allItems: items, buckets: { - movie: items.filter((item: any) => item?.type === 'movie'), - live: items.filter((item: any) => item?.type === 'live'), - series: items.filter((item: any) => item?.type === 'series'), + movie: items.filter((item) => item.type === 'movie'), + live: items.filter((item) => item.type === 'live'), + series: items.filter((item) => item.type === 'series'), }, - textOf: (item: any) => - `${item?.title ?? ''} ${item?.playlist_name ?? ''}`, + textOf: (item) => `${item.title} ${item.playlist_name ?? ''}`, }); }); - readonly liveItemsToShow = computed(() => - this.visibleRecentItems().filter((item: any) => { - if (item?.type !== 'live') { - return false; - } + readonly liveItemsToShow = computed(() => + this.visibleRecentItems() + .filter((item) => { + if (item.type !== 'live') { + return false; + } - const term = - this.isWorkspaceLayout && !this.isGlobal - ? this.workspaceSearchTerm() - : this.recentSearchTerm().trim().toLowerCase(); - if (!term) { - return true; - } + const term = + this.isWorkspaceLayout && !this.isGlobal + ? this.workspaceSearchTerm() + : this.recentSearchTerm().trim().toLowerCase(); + if (!term) { + return true; + } - return `${item?.title ?? ''} ${item?.playlist_name ?? ''}` - .toLowerCase() - .includes(term); - }) + return `${item.title} ${item.playlist_name ?? ''}` + .toLowerCase() + .includes(term); + }) + .map((item) => ({ + id: item.id, + poster_url: item.poster_url, + stream_icon: item.stream_icon, + title: item.title, + xtream_id: Number(item.xtream_id), + })) ); readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'live'); readonly isEmbeddedPlayer = computed(() => @@ -333,7 +368,11 @@ export class RecentlyViewedComponent { } } - openItem(item: any) { + openItem(item: FavoriteLayoutItem) { + if (!isXtreamRecentViewItem(item)) { + return; + } + const source = item.source ?? 'xtream'; if (source === 'stalker' && this.isGlobal) { @@ -365,7 +404,13 @@ export class RecentlyViewedComponent { if (!this.isGlobal && type === 'live') { this.setCategoryId('live'); - this.selectLiveItem(item); + this.selectLiveItem({ + id: item.id, + poster_url: item.poster_url, + stream_icon: item.stream_icon, + title: item.title, + xtream_id: Number(item.xtream_id), + }); return; } @@ -390,7 +435,11 @@ export class RecentlyViewedComponent { } } - async onRemoveItem(item: any) { + async onRemoveItem(item: FavoriteLayoutItem) { + if (!isXtreamRecentViewItem(item)) { + return; + } + try { if (this.isGlobal) { if (item.source === 'stalker') { @@ -410,9 +459,14 @@ export class RecentlyViewedComponent { return; } + const playlist = this.currentPlaylist(); + if (!playlist) { + return; + } + this.xtreamStore.removeRecentItem({ itemId: Number(item.id), - playlistId: this.currentPlaylist().id, + playlistId: playlist.id, }); } catch (error) { this.logger.error('Error removing recent item', error); @@ -454,7 +508,7 @@ export class RecentlyViewedComponent { this.collectionContext.setCategoryId(categoryId); } - selectLiveItem(item: any): void { + selectLiveItem(item: XtreamRecentLiveItem): void { this.xtreamStore.setSelectedContentType('live'); this.selectedLiveItem.set(item); const streamUrl = this.xtreamStore.constructStreamUrl(item); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts index 015e9f054..700cd6fcb 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts @@ -4,26 +4,48 @@ import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; import { of } from 'rxjs'; import { DatabaseService } from 'services'; import { SearchResultsComponent } from './search-results.component'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + SearchFilters, + XtreamContentItem, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; + +const DEFAULT_SEARCH_FILTERS: SearchFilters = { + live: true, + movie: true, + series: true, +}; + +function createSearchItem( + overrides: Partial = {} +): XtreamContentItem { + return { + id: 1, + category_id: 123, + title: 'Matrix', + rating: '0', + added: '0', + poster_url: '', + xtream_id: 456, + type: 'movie', + ...overrides, + }; +} class MockXtreamStore { readonly searchTerm = signal(''); - readonly searchFilters = signal({ - live: true, - movie: true, - series: true, - }); - readonly searchResults = signal([]); + readonly searchFilters = signal(DEFAULT_SEARCH_FILTERS); + readonly searchResults = signal([]); readonly isSearching = signal(false); setSearchTerm = jest.fn((term: string) => { this.searchTerm.set(term); }); - setSearchFilters = jest.fn((filters: any) => { + setSearchFilters = jest.fn((filters: SearchFilters) => { this.searchFilters.set(filters); }); updateSearchFilter = jest.fn(); - setGlobalSearchResults = jest.fn((items: any[]) => { + setGlobalSearchResults = jest.fn((items: XtreamContentItem[]) => { this.searchResults.set(items); }); setIsSearching = jest.fn((state: boolean) => { @@ -78,8 +100,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, initialQuery: 'matrix', - }, - undefined as any + } ) ); @@ -94,8 +115,7 @@ describe('SearchResultsComponent initialQuery contract', () => { new SearchResultsComponent( { isGlobalSearch: true, - }, - undefined as any + } ) ); @@ -109,17 +129,15 @@ describe('SearchResultsComponent initialQuery contract', () => { new SearchResultsComponent( { isGlobalSearch: true, - }, - undefined as any + } ) ); - component.selectItem({ - playlist_id: 'playlist-1', - type: 'movie', - category_id: 123, - xtream_id: 456, - } as any); + component.selectItem( + createSearchItem({ + playlist_id: 'playlist-1', + }) + ); expect(routerNavigateMock).toHaveBeenCalledWith([ '/workspace', diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index e66fd7794..7c9a67eee 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -189,7 +189,7 @@ export class SearchResultsComponent implements AfterViewInit { executeSearch() { const filters = this.filters(); const types = Object.entries(filters) - .filter(([_, enabled]) => enabled) + .filter(([, enabled]) => enabled) .map(([type]) => type); const excludeHidden = this.excludeHidden(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index f4d7e7128..584e3e839 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -22,8 +22,13 @@ import { PlayerContentInfo, ResolvedPortalPlayback, XtreamSerieEpisode, + XtreamSerieDetails, } from 'shared-interfaces'; +type XtreamSerieDetailsView = XtreamSerieDetails & { + readonly series_id: number; +}; + @Component({ selector: 'app-serial-details', templateUrl: './serial-details.component.html', @@ -47,7 +52,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); - readonly selectedItem = this.xtreamStore.selectedItem; + readonly selectedItem = signal(null); readonly selectedContentType = this.xtreamStore.selectedContentType; readonly isFavorite = this.xtreamStore.isFavorite; readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; @@ -65,6 +70,22 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly activeEpisodeId = signal(null); constructor() { + effect(() => { + const item = this.xtreamStore.selectedItem() as unknown as + | (XtreamSerieDetails & { + readonly series_id?: string | number; + }) + | null; + this.selectedItem.set( + item + ? { + ...item, + series_id: Number(item.series_id), + } + : null + ); + }); + effect(() => { const playlist = this.xtreamStore.currentPlaylist(); this.currentPlaylistId.set(playlist?.id ?? ''); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 107c6c231..05444090f 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -62,7 +62,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly isElectron = this.downloadsService.isAvailable; readonly isFavorite = this.xtreamStore.isFavorite; - readonly selectedItem = this.xtreamStore.selectedItem; + readonly selectedItem = computed( + () => + this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null + ); readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; readonly detailsError = this.xtreamStore.detailsError; private lastSaveTime = 0; diff --git a/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts index 57cef3660..889d688fa 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts @@ -10,7 +10,6 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { PlaylistSwitcherComponent, ResizableDirective } from 'components'; -import { XtreamCategory } from 'shared-interfaces'; import { CategoryViewComponent } from '@iptvnator/portal/shared/ui'; import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util'; import { @@ -98,9 +97,8 @@ export class XtreamMainContainerComponent implements OnInit { } } - categoryClicked(category: XtreamCategory) { - const categoryData = category as XtreamCategoryLike; - const categoryId = categoryData.category_id ?? categoryData.id; + categoryClicked(category: XtreamCategoryLike) { + const categoryId = category.category_id ?? category.id; this.xtreamStore.setSelectedItem(null); this.xtreamStore.setSelectedCategory(Number(categoryId)); diff --git a/libs/workspace/shell/feature/src/index.ts b/libs/workspace/shell/feature/src/index.ts index 759d86f8b..38b685be7 100644 --- a/libs/workspace/shell/feature/src/index.ts +++ b/libs/workspace/shell/feature/src/index.ts @@ -1,5 +1,3 @@ export * from './lib/global-favorites/global-favorites-page.component'; -export * from './lib/workspace-context-panel/settings-context.service'; -export * from './lib/workspace-shell/workspace-shell-actions'; export * from './lib/workspace-shell/workspace-shell.component'; export * from './lib/workspace-sources/workspace-sources.component'; diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts index 605fa1ab0..5952a4b43 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts @@ -32,6 +32,7 @@ import { Channel, EpgItem, EpgProgram, + Playlist, ResolvedPortalPlayback, STALKER_REQUEST, StalkerPortalActions, @@ -41,6 +42,12 @@ import { GlobalFavoritesListComponent } from './global-favorites-list.component' import { GlobalFavoritesService } from './global-favorites.service'; import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface'; +interface StalkerCreateLinkResponse { + readonly js?: { + readonly cmd?: string; + }; +} + @Component({ selector: 'app-global-favorites-page', templateUrl: './global-favorites-page.component.html', @@ -248,7 +255,7 @@ export class GlobalFavoritesPageComponent implements OnInit { epgMap: Map, now: number ): Promise { - let playlist: any; + let playlist: Playlist | undefined; try { playlist = await firstValueFrom( this.playlistsService.getPlaylistById(playlistId) @@ -257,6 +264,10 @@ export class GlobalFavoritesPageComponent implements OnInit { return; } + if (!playlist?.serverUrl || !playlist.username || !playlist.password) { + return; + } + const credentials = { serverUrl: playlist.serverUrl, username: playlist.username, @@ -355,9 +366,16 @@ export class GlobalFavoritesPageComponent implements OnInit { })); } if (ch.sourceType === 'xtream' && ch.xtreamId) { - const playlist = await firstValueFrom( + const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(ch.playlistId) - ); + )) as Playlist | undefined; + if ( + !playlist?.serverUrl || + !playlist.username || + !playlist.password + ) { + return []; + } return await this.xtreamApi.getShortEpg( { serverUrl: playlist.serverUrl, @@ -387,18 +405,23 @@ export class GlobalFavoritesPageComponent implements OnInit { private async resolveXtreamPlayback( ch: UnifiedFavoriteChannel ): Promise { - const playlist = await firstValueFrom( + const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(ch.playlistId) - ); + )) as Playlist | undefined; + if ( + ch.xtreamId == null || + !playlist?.serverUrl || + !playlist.username || + !playlist.password + ) { + throw new Error('Missing Xtream playback credentials'); + } const credentials = { serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, }; - const streamUrl = this.xtreamUrl.constructLiveUrl( - credentials, - ch.xtreamId! - ); + const streamUrl = this.xtreamUrl.constructLiveUrl(credentials, ch.xtreamId); return { streamUrl, title: ch.name, @@ -409,13 +432,13 @@ export class GlobalFavoritesPageComponent implements OnInit { private async resolveStalkerPlayback( ch: UnifiedFavoriteChannel ): Promise { - const playlist = await firstValueFrom( + const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(ch.playlistId) - ); + )) as Playlist | undefined; const portalUrl = - ch.stalkerPortalUrl ?? playlist.portalUrl ?? playlist.url; - const macAddress = ch.stalkerMacAddress ?? playlist.macAddress; + ch.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? ''; + const macAddress = ch.stalkerMacAddress ?? playlist?.macAddress ?? ''; const cmd = ch.stalkerCmd ?? ''; const params = { @@ -427,8 +450,8 @@ export class GlobalFavoritesPageComponent implements OnInit { JsHttpRequest: '1-xml', }; - let response: any; - if (playlist.isFullStalkerPortal) { + let response: StalkerCreateLinkResponse | undefined; + if (playlist?.isFullStalkerPortal && playlist) { response = await this.stalkerSession.makeAuthenticatedRequest( playlist, params diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index 89ccf759d..30da69025 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -16,7 +16,12 @@ import { Store } from '@ngrx/store'; import { selectAllPlaylistsMeta } from 'm3u-state'; import { firstValueFrom, map } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; -import { Channel, PlaylistMeta } from 'shared-interfaces'; +import { + Channel, + Playlist, + PlaylistMeta, + StalkerPortalItem, +} from 'shared-interfaces'; import { buildFavoriteUid, UnifiedFavoriteChannel, @@ -24,6 +29,29 @@ import { const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; +interface XtreamGlobalFavoriteRow { + readonly added_at?: string | null; + readonly id: number; + readonly playlist_id: string; + readonly playlist_name: string; + readonly position?: number | null; + readonly poster_url?: string | null; + readonly title: string; + readonly xtream_id: number; +} + +type PlaylistWithChannels = Playlist & { + readonly playlist?: { + readonly items?: Channel[]; + }; +}; + +function isStalkerFavoriteItem( + value: string | StalkerPortalItem +): value is StalkerPortalItem { + return typeof value !== 'string'; +} + @Injectable({ providedIn: 'root' }) export class GlobalFavoritesService { private readonly store = inject(Store); @@ -103,9 +131,14 @@ export class GlobalFavoritesService { // Persist xtream positions to DB const xtreamUpdates = channels - .filter((ch) => ch.sourceType === 'xtream' && ch.contentId != null) + .filter( + ( + ch + ): ch is UnifiedFavoriteChannel & { contentId: number } => + ch.sourceType === 'xtream' && ch.contentId != null + ) .map((ch, index) => ({ - content_id: ch.contentId!, + content_id: ch.contentId, position: index, })); @@ -138,16 +171,16 @@ export class GlobalFavoritesService { ); // Load full playlist to access channel items - let playlist: any; + let playlist: PlaylistWithChannels | undefined; try { - playlist = await firstValueFrom( + playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(meta._id) - ); + )) as PlaylistWithChannels | undefined; } catch { continue; } - const items: Channel[] = playlist?.playlist?.items ?? []; + const items = playlist?.playlist?.items ?? []; for (const ch of items) { if (favoriteIds.has(ch.id) || favoriteIds.has(ch.url)) { const sourceItemId = ch.url || ch.id; @@ -173,9 +206,10 @@ export class GlobalFavoritesService { private async getXtreamLiveFavorites(): Promise { if (!window.electron) return []; - let items: any[] = []; + let items: XtreamGlobalFavoriteRow[] = []; try { - items = await this.dbService.getGlobalFavorites(); + items = + (await this.dbService.getGlobalFavorites()) as XtreamGlobalFavoriteRow[]; } catch { return []; } @@ -211,17 +245,17 @@ export class GlobalFavoritesService { if (!meta.favorites?.length) continue; // Fetch full playlist to get credentials - let playlist: any; + let playlist: Playlist | undefined; try { - playlist = await firstValueFrom( + playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(meta._id) - ); + )) as Playlist | undefined; } catch { continue; } const favorites = Array.isArray(playlist?.favorites) - ? playlist.favorites + ? playlist.favorites.filter(isStalkerFavoriteItem) : []; for (const fav of favorites) { @@ -303,8 +337,9 @@ export class GlobalFavoritesService { const unordered: UnifiedFavoriteChannel[] = []; for (const ch of channels) { - if (orderMap.has(ch.uid)) { - ordered.push({ ...ch, position: orderMap.get(ch.uid)! }); + const position = orderMap.get(ch.uid); + if (position != null) { + ordered.push({ ...ch, position }); } else { unordered.push(ch); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 041c0890b..12eb33b36 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -23,7 +23,7 @@ interface WorkspaceContextRoute { interface XtreamCategoryLike { id?: number | string; - category_id?: string; + category_id?: number | string; } @Component({ diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts index 93a2d371e..79a483af4 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts @@ -29,7 +29,7 @@ import { FavoritesContextService } from '@iptvnator/portal/shared/util'; `, @@ -42,22 +42,25 @@ export class WorkspaceFavoritesContextPanelComponent { const categories = this.ctx.categories(); if (!categories.length) return 'Filter by type'; const selected = categories.find( - (c: any) => c.category_id === this.ctx.selectedCategoryId() - ) as any; + (c) => c.category_id === this.ctx.selectedCategoryId() + ); return selected?.category_name ?? 'Filter by type'; }); readonly countBadge = computed(() => { const categories = this.ctx.categories(); const selected = categories.find( - (c: any) => c.category_id === this.ctx.selectedCategoryId() - ) as any; + (c) => c.category_id === this.ctx.selectedCategoryId() + ); if (!selected || selected.count === undefined) return ''; return `${selected.count} ${selected.count === 1 ? 'item' : 'items'}`; }); - onCategoryClicked(item: { category_id?: string | number }): void { - const id = String(item.category_id ?? 'all'); + onCategoryClicked(item: { + category_id?: string | number; + id?: string | number; + }): void { + const id = String(item.category_id ?? item.id ?? 'all'); this.ctx.setCategoryId(id); } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index a9eb6c740..57010b7e9 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -2,7 +2,7 @@ import { Location } from '@angular/common'; import { Component, inject } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; -import { SettingsContextService } from './settings-context.service'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; @Component({ selector: 'app-workspace-settings-context-panel', diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts index a65c0a537..a5852704c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts @@ -14,16 +14,18 @@ import { PlaylistsService, SettingsStore, } from 'services'; -import { SettingsContextService } from '../workspace-context-panel/settings-context.service'; +import { + SettingsContextService, + WORKSPACE_SHELL_ACTIONS, +} from '@iptvnator/workspace/shell/util'; import { WorkspaceShellComponent } from './workspace-shell.component'; -import { WORKSPACE_SHELL_ACTIONS } from './workspace-shell-actions'; class MockXtreamStore { readonly contentSortMode = signal< 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' >('date-desc'); readonly getCategoryItemCounts = signal(new Map()); - readonly recentItems = signal([]); + readonly recentItems = signal([]); setContentSortMode = jest.fn(); setSearchTerm = jest.fn(); @@ -33,7 +35,7 @@ class MockXtreamStore { describe('WorkspaceShellComponent action matrix', () => { beforeEach(() => { - (window as any).electron = { platform: 'darwin' }; + window.electron = { platform: 'darwin' } as typeof window.electron; const selectSignal = jest .fn() diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts index 72b3a3fe9..d8195a45f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts @@ -48,20 +48,20 @@ import { PlaylistsService, SettingsStore, } from 'services'; +import { + SettingsContextService, + WorkspaceAccountInfoData, + WORKSPACE_SHELL_ACTIONS, +} from '@iptvnator/workspace/shell/util'; import { WorkspaceCommandItem, WorkspaceCommandPaletteComponent, WorkspaceCommandSelection, } from '../workspace-command-palette/workspace-command-palette.component'; -import { SettingsContextService } from '../workspace-context-panel/settings-context.service'; import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component'; import { WorkspaceFavoritesContextPanelComponent } from '../workspace-context-panel/workspace-favorites-context-panel.component'; import { WorkspaceSettingsContextPanelComponent } from '../workspace-context-panel/workspace-settings-context-panel.component'; import { WorkspaceSourcesFiltersPanelComponent } from '../workspace-sources-filters-panel/workspace-sources-filters-panel.component'; -import { - WorkspaceAccountInfoData, - WORKSPACE_SHELL_ACTIONS, -} from './workspace-shell-actions'; interface WorkspaceContext { provider: 'xtreams' | 'stalker' | 'playlists'; diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index 1ccecc424..23021853b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -5,7 +5,7 @@ import { Store } from '@ngrx/store'; import { PlaylistType, RecentPlaylistsComponent } from 'components'; import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state'; import { map } from 'rxjs'; -import { WORKSPACE_SHELL_ACTIONS } from '../workspace-shell/workspace-shell-actions'; +import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; @Component({ selector: 'app-workspace-sources', diff --git a/libs/workspace/shell/util/project.json b/libs/workspace/shell/util/project.json new file mode 100644 index 000000000..d8228d2c9 --- /dev/null +++ b/libs/workspace/shell/util/project.json @@ -0,0 +1,13 @@ +{ + "name": "workspace-shell-util", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/workspace/shell/util/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:workspace", "domain:workspace", "type:util"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/workspace/shell/util/src/index.ts b/libs/workspace/shell/util/src/index.ts new file mode 100644 index 000000000..994503b26 --- /dev/null +++ b/libs/workspace/shell/util/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/settings-context.service'; +export * from './lib/workspace-shell-actions'; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/settings-context.service.ts b/libs/workspace/shell/util/src/lib/settings-context.service.ts similarity index 100% rename from libs/workspace/shell/feature/src/lib/workspace-context-panel/settings-context.service.ts rename to libs/workspace/shell/util/src/lib/settings-context.service.ts diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell-actions.ts b/libs/workspace/shell/util/src/lib/workspace-shell-actions.ts similarity index 76% rename from libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell-actions.ts rename to libs/workspace/shell/util/src/lib/workspace-shell-actions.ts index c3037c131..3d81bc844 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell-actions.ts +++ b/libs/workspace/shell/util/src/lib/workspace-shell-actions.ts @@ -1,5 +1,6 @@ import { InjectionToken } from '@angular/core'; -import { PlaylistType } from 'components'; + +export type WorkspacePlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker'; export interface WorkspaceAccountInfoData { vodStreamsCount: number; @@ -8,7 +9,7 @@ export interface WorkspaceAccountInfoData { } export interface WorkspaceShellActions { - openAddPlaylistDialog(type: PlaylistType): void; + openAddPlaylistDialog(type: WorkspacePlaylistType): void; openGlobalSearch(initialQuery?: string): void; openGlobalRecent(): void; openAccountInfo(data: WorkspaceAccountInfoData): void; diff --git a/libs/workspace/shell/util/tsconfig.json b/libs/workspace/shell/util/tsconfig.json new file mode 100644 index 000000000..7d33479d8 --- /dev/null +++ b/libs/workspace/shell/util/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/workspace/shell/util/tsconfig.lib.json b/libs/workspace/shell/util/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/workspace/shell/util/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 8b365b25b..f53c63881 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -48,6 +48,9 @@ "@iptvnator/workspace/shell/feature": [ "libs/workspace/shell/feature/src/index.ts" ], + "@iptvnator/workspace/shell/util": [ + "libs/workspace/shell/util/src/index.ts" + ], "@iptvnator/pipes": ["libs/ui/pipes/src/index.ts"], "@package": ["./package.json"], "components": ["libs/ui/components/src/index.ts"],