refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 21:44:28 +01:00
1 parent 0ad1eeb785
commit aa739f43e0
63 files changed
+1113 -427

No files matched your search

@@ -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';
+56 -19
View File
@@ -20,6 +20,23 @@ import {
logPortalDebugEvent,
} from '@iptvnator/portal/shared/util';
interface PlayerLaunchPayload {
readonly headers?: Record<string, string>;
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<typeof logPortalDebugEvent>[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<string, string>;
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<string, string>;
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);
}
+45 -15
View File
@@ -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<PwaXtreamResponse>(`${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<string, string>;
@@ -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);
}
@@ -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,
@@ -43,12 +43,12 @@ export class SettingsService {
key: STORE_KEY,
value: unknown,
withCallback = false
): Observable<unknown> | never {
): Observable<unknown> | void {
if (withCallback) {
return this.storage.set(key, value);
} else {
this.storage.set(key, value).subscribe(() => {});
}
this.storage.set(key, value).subscribe();
}
/**
@@ -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 {
@@ -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<HTMLElement>);
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();
+65 -38
View File
@@ -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<string, unknown>;
type JsonArray = unknown[];
declare global {
interface Window {
@@ -14,12 +31,15 @@ declare global {
) => () => void;
getAppVersion: () => Promise<string>;
platform: string;
fetchPlaylistByUrl: (url: string, title?: string) => Promise<any>;
fetchPlaylistByUrl: (
url: string,
title?: string
) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
title: string
) => Promise<any>;
openPlaylistFromFile: () => Promise<any>;
) => Promise<Playlist>;
openPlaylistFromFile: () => Promise<Playlist>;
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<string, string>
) => Promise<ExternalPlayerSession>;
@@ -47,11 +67,11 @@ declare global {
userAgent: string,
referer?: string,
origin?: string,
contentInfo?: any,
contentInfo?: unknown,
startTime?: number,
headers?: Record<string, string>
) => Promise<ExternalPlayerSession>;
autoUpdatePlaylists: (playlists: any[]) => Promise<any[]>;
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
fetchEpg: (
urls: string[]
) => Promise<{
@@ -59,12 +79,12 @@ declare global {
message?: string;
skipped?: string[];
}>;
getChannelPrograms: (channelId: string) => Promise<any>;
getEpgChannels: () => Promise<any>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getEpgChannels: () => Promise<EpgChannel[]>;
getEpgChannelsByRange: (
skip: number,
limit: number
) => Promise<any>;
) => Promise<EpgChannel[]>;
forceFetchEpg: (
url: string
) => Promise<{ success: boolean; message?: string }>;
@@ -76,8 +96,8 @@ declare global {
searchEpgPrograms: (
searchTerm: string,
limit?: number
) => Promise<any[]>;
updateSettings: (settings: any) => Promise<void>;
) => Promise<EpgProgram[]>;
updateSettings: (settings: JsonObject) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
aiModelName: string;
@@ -92,26 +112,28 @@ declare global {
token?: string;
serialNumber?: string;
requestId?: string;
}) => Promise<any>;
}) => Promise<JsonObject>;
xtreamRequest: (payload: {
url: string;
params: Record<string, string>;
requestId?: string;
}) => Promise<{ payload: any; action: string }>;
}) => Promise<{ payload: unknown; action: string }>;
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
dbCreatePlaylist: (
playlist: Playlist
) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbUpsertAppPlaylist: (
playlist: any
playlist: Playlist
) => Promise<{ success: boolean }>;
dbUpsertAppPlaylists: (
playlists: any[]
playlists: Playlist[]
) => Promise<{ success: boolean; count: number }>;
dbGetAppPlaylists: () => Promise<any[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<any | null>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbUpdatePlaylist: (
playlistId: string,
updates: any
updates: Partial<Playlist>
) => Promise<{ success: boolean }>;
dbDeletePlaylist: (
playlistId: string
@@ -143,17 +165,17 @@ declare global {
dbGetCategories: (
playlistId: string,
type: string
) => Promise<any[]>;
) => Promise<XCategoryFromDb[]>;
dbSaveCategories: (
playlistId: string,
categories: any[],
categories: XCategoryFromDb[],
type: string,
hiddenCategoryXtreamIds?: number[]
) => Promise<{ success: boolean }>;
dbGetAllCategories: (
playlistId: string,
type: string
) => Promise<any[]>;
) => Promise<XCategoryFromDb[]>;
dbUpdateCategoryVisibility: (
categoryIds: number[],
hidden: boolean
@@ -162,10 +184,13 @@ declare global {
playlistId: string,
type: string
) => Promise<boolean>;
dbGetContent: (playlistId: string, type: string) => Promise<any[]>;
dbGetContent: (
playlistId: string,
type: string
) => Promise<XtreamContent[]>;
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<any[]>;
) => Promise<XtreamContent[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[],
excludeHidden?: boolean
) => Promise<any[]>;
dbGetRecentlyViewed: () => Promise<any[]>;
) => Promise<GlobalSearchResult[]>;
dbGetRecentlyViewed: () => Promise<GlobalRecentItem[]>;
dbClearRecentlyViewed: () => Promise<{ success: boolean }>;
// Favorites
dbAddFavorite: (
@@ -194,13 +219,13 @@ declare global {
contentId: number,
playlistId: string
) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetGlobalFavorites: () => Promise<any[]>;
dbGetFavorites: (playlistId: string) => Promise<XtreamContent[]>;
dbGetGlobalFavorites: () => Promise<GlobalFavoriteItem[]>;
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => Promise<{ success: boolean }>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
dbGetRecentItems: (playlistId: string) => Promise<XtreamContent[]>;
dbAddRecentItem: (
contentId: number,
playlistId: string
@@ -215,7 +240,7 @@ declare global {
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string
) => Promise<any | null>;
) => Promise<XtreamContent | null>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
key: string,
@@ -293,23 +318,25 @@ declare global {
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) => Promise<any | null>;
) => Promise<PlaybackPositionData | null>;
dbGetSeriesPlaybackPositions: (
playlistId: string,
seriesXtreamId: number
) => Promise<any[]>;
) => Promise<PlaybackPositionData[]>;
dbGetRecentPlaybackPositions: (
playlistId: string,
limit?: number
) => Promise<any[]>;
dbGetAllPlaybackPositions: (playlistId: string) => Promise<any[]>;
) => Promise<PlaybackPositionData[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) => Promise<{ success: boolean }>;
onPlaybackPositionUpdate: (
callback: (data: any) => void
callback: (data: PlaybackPositionData) => void
) => () => void;
closeExternalPlayerSession: (
sessionId: string
Vendored
+2
View File
@@ -95,10 +95,12 @@ declare global {
params: Record<string, string>;
token?: string;
serialNumber?: string;
requestId?: string;
}) => Promise<any>;
xtreamRequest: (payload: {
url: string;
params: Record<string, string>;
requestId?: string;
}) => Promise<{ payload: any; action: string }>;
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
@@ -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,
@@ -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<CategoryViewItem[]>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map());
readonly showCounts = input<boolean>(false);
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly categoryClicked = output<XtreamCategory>();
readonly categoryClicked = output<CategoryViewItem>();
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;
}
}
@@ -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<any[]>([]);
readonly favorites = input<any[]>([]);
readonly categories = input<XtreamCategory[]>([]);
readonly favorites = input<FavoriteLayoutItem[]>([]);
readonly playlistSubtitle = input<string>('');
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('movie');
@@ -44,20 +74,20 @@ export class FavoritesLayoutComponent {
readonly headerActionIcon = input<string>('delete_sweep');
readonly headerActionTooltip = input<string>('');
readonly categoryClicked = output<any>();
readonly removeFavorite = output<any>();
readonly openItem = output<any>();
readonly categoryClicked = output<FavoriteLayoutSelection>();
readonly removeFavorite = output<FavoriteLayoutItem>();
readonly openItem = output<FavoriteLayoutItem>();
readonly headerActionClicked = output<void>();
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);
}
@@ -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<XtreamCategory[]>([]);
readonly items = input<any[]>([]);
readonly items = input<FavoriteLayoutItem[]>([]);
readonly playlistSubtitle = input<string>('');
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('all');
@@ -38,15 +46,16 @@ export class PortalCollectionShellComponent {
readonly mode = input<PortalCollectionMode>('grid');
readonly categoryClicked = output<string>();
readonly removeItem = output<any>();
readonly openItem = output<any>();
readonly removeItem = output<FavoriteLayoutItem>();
readonly openItem = output<FavoriteLayoutItem>();
readonly headerActionClicked = output<void>();
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));
}
}
@@ -1,7 +1,6 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
input,
output,
viewChild,
@@ -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<boolean>(false);
readonly selectedLiveItem = signal<
(StalkerVodSource & { category_id: string }) | null
>(null);
readonly selectedLiveItem = signal<StalkerVodSource | null>(null);
readonly liveStreamUrl = signal('');
readonly epgItems = signal<EpgItem[]>([]);
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);
@@ -29,8 +29,8 @@ export class StalkerCollectionChannelsListComponent {
readonly selectedItemId = input<string | number | null>(null);
readonly favoriteIds = input<Map<string | number, boolean>>(new Map());
readonly playClicked = output<any>();
readonly favoriteToggled = output<any>();
readonly playClicked = output<StalkerVodSource>();
readonly favoriteToggled = output<StalkerVodSource>();
private readonly stalkerStore = inject(StalkerStore);
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
@@ -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<boolean>(false);
readonly selectedLiveItem = signal<StalkerFavoriteItem | null>(null);
readonly selectedLiveItem = signal<StalkerVodSource | null>(null);
readonly liveStreamUrl = signal('');
readonly epgItems = signal<EpgItem[]>([]);
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 });
@@ -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);
}
@@ -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,
});
}
@@ -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<number> {
@@ -309,7 +323,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
// Playback Position Operations
// =========================================================================
async savePlaybackPosition(playlistId: string, data: any): Promise<void> {
async savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
await this.playbackService.savePlaybackPosition(playlistId, data);
}
@@ -317,7 +334,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<any | null> {
): Promise<PlaybackPositionData | null> {
return this.playbackService.getPlaybackPosition(
playlistId,
contentXtreamId,
@@ -328,7 +345,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<any[]> {
): Promise<PlaybackPositionData[]> {
return this.playbackService.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
@@ -338,14 +355,16 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<any[]> {
): Promise<PlaybackPositionData[]> {
return this.playbackService.getRecentPlaybackPositions(
playlistId,
limit
);
}
async getAllPlaybackPositions(playlistId: string): Promise<any[]> {
async getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]> {
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.
}
@@ -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);
}
@@ -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<string, XtreamCategory[]>();
private contentCache = new Map<string, any[]>();
private contentCache = new Map<string, XtreamCachedContentItem[]>();
// =========================================================================
// 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<XtreamCategoryFromDb[]> {
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> {
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<number> {
@@ -229,16 +263,17 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistId: string,
searchTerm: string,
types: string[],
_excludeHidden?: boolean
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
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<void> {
async savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
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<any | null> {
): Promise<PlaybackPositionData | null> {
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<any[]> {
): Promise<PlaybackPositionData[]> {
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<any[]> {
): Promise<PlaybackPositionData[]> {
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<any[]> {
async getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]> {
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<string, any[]> {
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<string, any[]>
positions: Record<string, PlaybackPositionData[]>
): 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<string, { id: number; viewedAt: string }[]>
recentItems: Record<string, StoredRecentItem[]>
): 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,
})
@@ -227,7 +227,11 @@ export interface IXtreamDataSource {
*/
saveContent(
playlistId: string,
streams: any[],
streams:
| XtreamLiveStream[]
| XtreamVodStream[]
| XtreamSerieItem[]
| XtreamContentItem[],
type: 'live' | 'movie' | 'series',
onProgress?: ProgressCallback
): Promise<number>;
@@ -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;
@@ -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;
@@ -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,
@@ -261,16 +261,23 @@ export class XtreamApiService {
/**
* Send request via IPC to avoid CORS issues
*/
private async sendRequest(url: string, params: Record<string, string | number>): Promise<any> {
private async sendRequest<TResponse>(
url: string,
params: Record<string, string | number>
): Promise<TResponse> {
const serializedParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
serializedParams[key] = String(value);
});
const response = await this.dataService.sendIpcEvent(XTREAM_REQUEST, {
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;
}
}
@@ -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}`;
}
@@ -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<ContentState>(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?.();
@@ -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<EpgState>(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) {
@@ -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);
}
@@ -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';
@@ -60,6 +60,10 @@ const initialSearchState: SearchState = {
*/
export function withSearch() {
const logger = createLogger('withSearch');
type ParentSearchStoreLike = {
playlistId?: () => string | null;
};
return signalStoreFeature(
withState<SearchState>(initialSearchState),
@@ -76,7 +80,7 @@ export function withSearch() {
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
// 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,
});
@@ -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<number, number> => {
const buildCountMap = (
streams: XtreamSelectionItem[]
): Map<number, number> => {
const countMap = new Map<number, number>();
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 });
},
@@ -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<unknown>;
}
).searchContent as (
term: string,
types: string[],
excludeHidden?: boolean
@@ -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<string, unknown>;
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
@@ -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;
@@ -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<FavoriteItem | null>(null);
readonly selectedLiveItem = signal<XtreamFavoriteLiveItem | null>(null);
readonly liveStreamUrl = signal('');
readonly mode = computed<PortalCollectionMode>(() =>
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
);
}
@@ -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<number, boolean>();
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<
@@ -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<any>();
readonly playClicked = output<XtreamChannelListItem>();
readonly sortMode = input<LiveChannelSortMode>('server');
readonly channelsOverride = input<any[] | null>(null);
readonly channelsOverride = input<XtreamChannelListItem[] | null>(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(
@@ -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<T extends RecentlyAddedItem>(
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') {
@@ -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<XtreamRecentViewItem[]>(() =>
(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<any | null>(null);
readonly selectedLiveItem = signal<XtreamRecentLiveItem | null>(null);
readonly liveStreamUrl = signal('');
readonly nonGlobalMode = computed<PortalCollectionMode>(() =>
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<XtreamRecentLiveItem[]>(() =>
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);
@@ -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> = {}
): 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<any[]>([]);
readonly searchFilters = signal(DEFAULT_SEARCH_FILTERS);
readonly searchResults = signal<XtreamContentItem[]>([]);
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',
@@ -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();
@@ -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<XtreamSerieDetailsView | null>(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<number | null>(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 ?? '');
@@ -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;
@@ -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));
@@ -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';
@@ -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<string, EpgProgram | null>,
now: number
): Promise<void> {
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<ResolvedPortalPlayback> {
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<ResolvedPortalPlayback> {
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
@@ -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<UnifiedFavoriteChannel[]> {
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);
}
@@ -23,7 +23,7 @@ interface WorkspaceContextRoute {
interface XtreamCategoryLike {
id?: number | string;
category_id?: string;
category_id?: number | string;
}
@Component({
@@ -29,7 +29,7 @@ import { FavoritesContextService } from '@iptvnator/portal/shared/util';
<app-category-view
[items]="ctx.categories()"
[selectedCategoryId]="ctx.selectedCategoryId()"
(categoryClicked)="onCategoryClicked($any($event))"
(categoryClicked)="onCategoryClicked($event)"
/>
</div>
`,
@@ -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);
}
}
@@ -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',
@@ -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<number, number>());
readonly recentItems = signal<any[]>([]);
readonly recentItems = signal<unknown[]>([]);
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()
@@ -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';
@@ -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',
+13
View File
@@ -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"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './lib/settings-context.service';
export * from './lib/workspace-shell-actions';
@@ -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;
+28
View File
@@ -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"
}
]
}
@@ -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"]
}
+3
View File
@@ -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"],