mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor: update nx monorepo structure
This commit is contained in:
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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+65
-38
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
-8
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+16
-7
@@ -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);
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+10
-9
@@ -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 });
|
||||
|
||||
+2
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+34
-14
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+48
-21
@@ -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<
|
||||
|
||||
+26
-10
@@ -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);
|
||||
|
||||
+39
-21
@@ -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';
|
||||
+38
-15
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ interface WorkspaceContextRoute {
|
||||
|
||||
interface XtreamCategoryLike {
|
||||
id?: number | string;
|
||||
category_id?: string;
|
||||
category_id?: number | string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
+10
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+6
-4
@@ -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';
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './lib/settings-context.service';
|
||||
export * from './lib/workspace-shell-actions';
|
||||
File renamed without changes.
+3
-2
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user