Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'playlist-shared-util',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/playlist/shared/util',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "playlist-shared-util",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playlist/shared/util/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:util"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/playlist/shared/util/jest.config.ts",
|
||||
"tsConfig": "libs/playlist/shared/util/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './lib/playlist-player-actions';
|
||||
export * from './lib/playlist-context.facade';
|
||||
export * from './lib/playlist-file-import.service';
|
||||
export * from './lib/playlist-refresh-action.service';
|
||||
@@ -0,0 +1,422 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActivePlaylistId,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { Subject } from 'rxjs';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
PlaylistContextFacade,
|
||||
PlaylistRouteContext,
|
||||
} from './playlist-context.facade';
|
||||
|
||||
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2';
|
||||
|
||||
function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
count: overrides.count ?? 0,
|
||||
importDate:
|
||||
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
autoRefresh: overrides.autoRefresh ?? false,
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('PlaylistContextFacade', () => {
|
||||
let facade: PlaylistContextFacade;
|
||||
let routerEvents: Subject<NavigationEnd>;
|
||||
let router: {
|
||||
url: string;
|
||||
events: ReturnType<Subject<NavigationEnd>['asObservable']>;
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
let dispatch: jest.Mock;
|
||||
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let loadedSignal: ReturnType<typeof signal<boolean>>;
|
||||
let activePlaylistIdSignal: ReturnType<typeof signal<string | null>>;
|
||||
let originalElectron: unknown;
|
||||
|
||||
const xtreamA = createPlaylist({
|
||||
_id: 'xtream-a',
|
||||
password: 'secret',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Xtream A',
|
||||
username: 'user-a',
|
||||
});
|
||||
const xtreamB = createPlaylist({
|
||||
_id: 'xtream-b',
|
||||
password: 'secret',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Xtream B',
|
||||
username: 'user-b',
|
||||
});
|
||||
const stalkerA = createPlaylist({
|
||||
_id: 'stalker-a',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Stalker A',
|
||||
});
|
||||
const stalkerB = createPlaylist({
|
||||
_id: 'stalker-b',
|
||||
macAddress: '00:1A:79:00:00:02',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Stalker B',
|
||||
});
|
||||
const m3uA = createPlaylist({
|
||||
_id: 'm3u-a',
|
||||
title: 'M3U A',
|
||||
url: 'http://example.test/a.m3u',
|
||||
});
|
||||
const m3uB = createPlaylist({
|
||||
_id: 'm3u-b',
|
||||
title: 'M3U B',
|
||||
url: 'http://example.test/b.m3u',
|
||||
});
|
||||
|
||||
function setElectronAvailability(enabled: boolean): void {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: enabled ? {} : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function instantiateFacade(): PlaylistContextFacade {
|
||||
facade = TestBed.inject(PlaylistContextFacade);
|
||||
dispatch.mockClear();
|
||||
router.navigate.mockClear();
|
||||
return facade;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
originalElectron = (window as Window & { electron?: unknown }).electron;
|
||||
|
||||
routerEvents = new Subject<NavigationEnd>();
|
||||
router = {
|
||||
url: '/workspace/xtreams/xtream-a/live',
|
||||
events: routerEvents.asObservable(),
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
dispatch = jest.fn((action: unknown) => action);
|
||||
playlistsSignal = signal([
|
||||
xtreamA,
|
||||
xtreamB,
|
||||
stalkerA,
|
||||
stalkerB,
|
||||
m3uA,
|
||||
m3uB,
|
||||
]);
|
||||
loadedSignal = signal(true);
|
||||
activePlaylistIdSignal = signal<string | null>(xtreamA._id);
|
||||
setElectronAvailability(true);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlaylistContextFacade,
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
dispatch,
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
if (selector === selectAllPlaylistsMeta) {
|
||||
return playlistsSignal;
|
||||
}
|
||||
if (selector === selectPlaylistsLoadingFlag) {
|
||||
return loadedSignal;
|
||||
}
|
||||
if (selector === selectActivePlaylistId) {
|
||||
return activePlaylistIdSignal;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unexpected selector: ${String(selector)}`
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: originalElectron,
|
||||
});
|
||||
routerEvents.complete();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('preserves same-provider Xtream sections when switching through selectPlaylist', () => {
|
||||
router.url = '/workspace/xtreams/xtream-a/live';
|
||||
const service = instantiateFacade();
|
||||
|
||||
service.selectPlaylist(xtreamB);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.setActivePlaylist({ playlistId: xtreamB._id })
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'workspace',
|
||||
'xtreams',
|
||||
xtreamB._id,
|
||||
'live',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves same-provider Stalker sections when resolving target commands', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'stalker',
|
||||
playlistId: stalkerA._id,
|
||||
section: 'downloads',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerB._id, 'downloads']);
|
||||
});
|
||||
|
||||
it('preserves same-provider M3U sections when resolving target commands', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'playlists',
|
||||
playlistId: m3uA._id,
|
||||
section: 'groups',
|
||||
})
|
||||
).toEqual(['workspace', 'playlists', m3uB._id, 'groups']);
|
||||
});
|
||||
|
||||
it('maps Stalker ITV routes to Xtream live routes', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'stalker',
|
||||
playlistId: stalkerA._id,
|
||||
section: 'itv',
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamA._id, 'live']);
|
||||
});
|
||||
|
||||
it('maps Xtream live routes to Stalker ITV routes', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerA._id, 'itv']);
|
||||
});
|
||||
|
||||
it('maps Xtream recently-added routes to Stalker recent routes', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'recently-added',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerA._id, 'recent']);
|
||||
});
|
||||
|
||||
it('preserves recent and favorites sections across provider changes when both providers support them', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'playlists',
|
||||
playlistId: m3uA._id,
|
||||
section: 'recent',
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamA._id, 'recent']);
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'playlists',
|
||||
playlistId: m3uA._id,
|
||||
section: 'favorites',
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerA._id, 'favorites']);
|
||||
});
|
||||
|
||||
it('falls back to M3U all when the current section has no M3U equivalent', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'playlists', m3uA._id, 'all']);
|
||||
});
|
||||
|
||||
it('does not let stale M3U memory override the fallback to all for portal-only sections', () => {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
playlists: 'recent',
|
||||
},
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'recent',
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uA._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'playlists', m3uA._id, 'all']);
|
||||
});
|
||||
|
||||
it('uses playlist-specific section memory when the current route has no section', () => {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
providers: {},
|
||||
playlists: {
|
||||
[xtreamB._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'series',
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: null,
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamB._id, 'series']);
|
||||
});
|
||||
|
||||
it('uses provider-level section memory when no route section or playlist memory exists', () => {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
stalker: 'search',
|
||||
},
|
||||
playlists: {},
|
||||
})
|
||||
);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('stalker', stalkerB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: null,
|
||||
})
|
||||
).toEqual(['workspace', 'stalker', stalkerB._id, 'search']);
|
||||
});
|
||||
|
||||
it('omits Xtream sections outside Electron where section navigation is unsupported', () => {
|
||||
setElectronAvailability(false);
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('xtreams', xtreamB._id, {
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'live',
|
||||
})
|
||||
).toEqual(['workspace', 'xtreams', xtreamB._id]);
|
||||
});
|
||||
|
||||
it('returns null when switching from the workspace root before a provider route is selected', () => {
|
||||
const service = instantiateFacade();
|
||||
|
||||
expect(
|
||||
service.resolveTargetCommands('playlists', m3uA._id, {
|
||||
inWorkspace: true,
|
||||
provider: null,
|
||||
playlistId: null,
|
||||
section: null,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('syncs section memory from router navigation events', () => {
|
||||
router.url = '/workspace/playlists/m3u-a/favorites';
|
||||
const service = instantiateFacade();
|
||||
|
||||
router.url = '/workspace/xtreams/xtream-a/recently-added';
|
||||
routerEvents.next(
|
||||
new NavigationEnd(
|
||||
1,
|
||||
'/workspace/xtreams/xtream-a/recently-added',
|
||||
'/workspace/xtreams/xtream-a/recently-added'
|
||||
)
|
||||
);
|
||||
|
||||
expect(service.routeContext()).toEqual<PlaylistRouteContext>({
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: xtreamA._id,
|
||||
section: 'recently-added',
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}'))
|
||||
.toEqual({
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'favorites',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
[xtreamA._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'recently-added',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
},
|
||||
providers: {
|
||||
playlists: 'favorites',
|
||||
xtreams: 'recently-added',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActivePlaylistId,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { filter } from 'rxjs';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
PortalProvider,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
export interface PlaylistRouteContext {
|
||||
inWorkspace: boolean;
|
||||
provider: PortalProvider | null;
|
||||
playlistId: string | null;
|
||||
section: PortalRailSection | null;
|
||||
}
|
||||
|
||||
type PlaylistSectionMemory = {
|
||||
providers: Partial<Record<PortalProvider, string>>;
|
||||
playlists: Record<
|
||||
string,
|
||||
{
|
||||
provider: PortalProvider;
|
||||
section: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2';
|
||||
const LEGACY_LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections';
|
||||
const LAST_ACTIVE_PLAYLIST_STORAGE_KEY =
|
||||
'playlist-switcher:last-active-playlist-id';
|
||||
const XTREAM_SECTIONS = [
|
||||
'live',
|
||||
'vod',
|
||||
'series',
|
||||
'favorites',
|
||||
'recent',
|
||||
'search',
|
||||
'recently-added',
|
||||
'downloads',
|
||||
] as const;
|
||||
const STALKER_SECTIONS = [
|
||||
'itv',
|
||||
'vod',
|
||||
'series',
|
||||
'favorites',
|
||||
'recent',
|
||||
'search',
|
||||
'downloads',
|
||||
] as const;
|
||||
const M3U_SECTIONS = ['all', 'groups', 'favorites', 'recent'] as const;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistContextFacade {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId);
|
||||
|
||||
readonly currentUrl = signal(this.router.url);
|
||||
readonly routeContext = computed(() =>
|
||||
this.getRouteContext(this.currentUrl())
|
||||
);
|
||||
readonly routeProvider = computed(() => this.routeContext().provider);
|
||||
readonly routePlaylistId = computed(() => this.routeContext().playlistId);
|
||||
readonly routeSection = computed(() => this.routeContext().section);
|
||||
readonly resolvedPlaylistId = computed(() => {
|
||||
const routePlaylistId = this.routePlaylistId();
|
||||
if (routePlaylistId) {
|
||||
return routePlaylistId;
|
||||
}
|
||||
|
||||
const activePlaylistId = this.activePlaylistId();
|
||||
return activePlaylistId || null;
|
||||
});
|
||||
readonly activePlaylist = computed(() => {
|
||||
const playlistId = this.resolvedPlaylistId();
|
||||
if (!playlistId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
this.playlists().find((playlist) => playlist._id === playlistId) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
readonly activeProvider = computed(() => {
|
||||
const playlist = this.activePlaylist();
|
||||
return playlist ? this.getProviderForPlaylist(playlist) : null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.clearLegacySectionMemory();
|
||||
this.syncFromUrl(this.router.url);
|
||||
|
||||
effect(() => {
|
||||
const allPlaylistsLoaded = this.allPlaylistsLoaded();
|
||||
const playlistIds = this.playlists().map(
|
||||
(playlist) => playlist._id
|
||||
);
|
||||
const playlistIdSet = new Set(playlistIds);
|
||||
const selectedId = this.activePlaylistId() || null;
|
||||
const routeId = this.routePlaylistId();
|
||||
const storedId = this.readPersistedActivePlaylistId();
|
||||
|
||||
if (!allPlaylistsLoaded) {
|
||||
if (routeId) {
|
||||
this.writePersistedActivePlaylistId(routeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedId) {
|
||||
this.writePersistedActivePlaylistId(selectedId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeDeletedPlaylistsFromSectionMemory(playlistIds);
|
||||
|
||||
if (routeId && playlistIdSet.has(routeId)) {
|
||||
this.writePersistedActivePlaylistId(routeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedId && playlistIdSet.has(selectedId)) {
|
||||
this.writePersistedActivePlaylistId(selectedId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!routeId &&
|
||||
!selectedId &&
|
||||
storedId &&
|
||||
playlistIdSet.has(storedId)
|
||||
) {
|
||||
this.activatePlaylist(storedId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedId && !playlistIdSet.has(storedId)) {
|
||||
this.clearPersistedActivePlaylistId();
|
||||
}
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(
|
||||
(event): event is NavigationEnd =>
|
||||
event instanceof NavigationEnd
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe((event) => {
|
||||
this.syncFromUrl(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
syncFromUrl(url = this.router.url): PlaylistRouteContext {
|
||||
this.currentUrl.set(url);
|
||||
const routeContext = this.routeContext();
|
||||
|
||||
this.rememberSection(routeContext);
|
||||
|
||||
if (routeContext.playlistId) {
|
||||
this.activatePlaylist(routeContext.playlistId);
|
||||
}
|
||||
|
||||
return routeContext;
|
||||
}
|
||||
|
||||
activatePlaylist(playlistId: string): void {
|
||||
if (!playlistId || this.activePlaylistId() === playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(PlaylistActions.setActivePlaylist({ playlistId }));
|
||||
}
|
||||
|
||||
selectPlaylist(playlist: PlaylistMeta): void {
|
||||
const routeContext = this.routeContext();
|
||||
const provider = this.getProviderForPlaylist(playlist);
|
||||
|
||||
this.activatePlaylist(playlist._id);
|
||||
|
||||
const targetCommands = this.resolveTargetCommands(
|
||||
provider,
|
||||
playlist._id,
|
||||
routeContext
|
||||
);
|
||||
|
||||
if (targetCommands) {
|
||||
void this.router.navigate(targetCommands);
|
||||
}
|
||||
}
|
||||
|
||||
resolveTargetCommands(
|
||||
provider: PortalProvider,
|
||||
playlistId: string,
|
||||
routeContext = this.routeContext()
|
||||
): string[] | null {
|
||||
const prefix = routeContext.inWorkspace ? ['workspace'] : [];
|
||||
|
||||
if (routeContext.inWorkspace && !routeContext.provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
const section = this.resolveTargetSection(
|
||||
provider,
|
||||
routeContext.section,
|
||||
playlistId
|
||||
);
|
||||
return [...prefix, 'playlists', playlistId, section];
|
||||
}
|
||||
|
||||
if (!this.supportsSectionNavigation(provider)) {
|
||||
return [...prefix, provider, playlistId];
|
||||
}
|
||||
|
||||
const section = this.resolveTargetSection(
|
||||
provider,
|
||||
routeContext.section,
|
||||
playlistId
|
||||
);
|
||||
return [...prefix, provider, playlistId, section];
|
||||
}
|
||||
|
||||
getProviderForPlaylist(playlist: PlaylistMeta): PortalProvider {
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtreams';
|
||||
}
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
return 'playlists';
|
||||
}
|
||||
|
||||
private isWorkspaceRoute(url: string): boolean {
|
||||
return /^\/workspace(?:\/|$)/.test(url);
|
||||
}
|
||||
|
||||
private getRouteContext(url: string): PlaylistRouteContext {
|
||||
const match = url.match(
|
||||
/^\/(?:workspace\/)?(playlists|xtreams|stalker)\/([^/?]+)(?:\/([^/?]+))?/
|
||||
);
|
||||
|
||||
return {
|
||||
inWorkspace: this.isWorkspaceRoute(url),
|
||||
provider: (match?.[1] as PortalProvider | undefined) ?? null,
|
||||
playlistId: match?.[2] ?? null,
|
||||
section: (match?.[3] as PortalRailSection | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private rememberSection(routeContext: PlaylistRouteContext): void {
|
||||
if (!routeContext.provider || !routeContext.section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeSectionForProvider(
|
||||
routeContext.section,
|
||||
routeContext.provider
|
||||
);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memory = this.readSectionMemory();
|
||||
memory.providers[routeContext.provider] = normalized;
|
||||
if (routeContext.playlistId) {
|
||||
memory.playlists[routeContext.playlistId] = {
|
||||
provider: routeContext.provider,
|
||||
section: normalized,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
this.writeSectionMemory(memory);
|
||||
}
|
||||
|
||||
private resolveTargetSection(
|
||||
provider: PortalProvider,
|
||||
currentSection: PortalRailSection | null,
|
||||
targetPlaylistId: string
|
||||
): string {
|
||||
const fromCurrent = this.normalizeSectionForProvider(
|
||||
currentSection,
|
||||
provider
|
||||
);
|
||||
if (fromCurrent) {
|
||||
return fromCurrent;
|
||||
}
|
||||
|
||||
if (provider === 'playlists' && currentSection) {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const memory = this.readSectionMemory();
|
||||
const playlistMemory = memory.playlists[targetPlaylistId];
|
||||
if (playlistMemory?.provider === provider) {
|
||||
const normalizedPlaylistSection = this.normalizeSectionForProvider(
|
||||
playlistMemory.section,
|
||||
provider
|
||||
);
|
||||
if (normalizedPlaylistSection) {
|
||||
return normalizedPlaylistSection;
|
||||
}
|
||||
}
|
||||
|
||||
const providerMemory = memory.providers[provider];
|
||||
const normalizedProviderSection = this.normalizeSectionForProvider(
|
||||
providerMemory,
|
||||
provider
|
||||
);
|
||||
if (normalizedProviderSection) {
|
||||
return normalizedProviderSection;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
private supportsSectionNavigation(provider: PortalProvider): boolean {
|
||||
if (provider === 'xtreams') {
|
||||
return Boolean(window.electron);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private normalizeSectionForProvider(
|
||||
section: string | null | undefined,
|
||||
provider: PortalProvider
|
||||
): string | null {
|
||||
if (!section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === 'xtreams') {
|
||||
if (section === 'itv') {
|
||||
return 'live';
|
||||
}
|
||||
|
||||
return XTREAM_SECTIONS.includes(
|
||||
section as (typeof XTREAM_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return M3U_SECTIONS.includes(
|
||||
section as (typeof M3U_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
|
||||
if (section === 'live') {
|
||||
return 'itv';
|
||||
}
|
||||
if (section === 'recently-added') {
|
||||
return 'recent';
|
||||
}
|
||||
|
||||
return STALKER_SECTIONS.includes(
|
||||
section as (typeof STALKER_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
|
||||
private readSectionMemory(): PlaylistSectionMemory {
|
||||
const empty: PlaylistSectionMemory = { providers: {}, playlists: {} };
|
||||
|
||||
try {
|
||||
this.clearLegacySectionMemory();
|
||||
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return empty;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const providers: Partial<Record<PortalProvider, string>> = {};
|
||||
const playlists: PlaylistSectionMemory['playlists'] = {};
|
||||
|
||||
const parsedProviders = (parsed as Record<string, unknown>)[
|
||||
'providers'
|
||||
];
|
||||
if (typeof parsedProviders === 'object' && parsedProviders !== null) {
|
||||
const candidate = parsedProviders as Record<string, unknown>;
|
||||
if (typeof candidate['playlists'] === 'string') {
|
||||
providers.playlists = candidate['playlists'];
|
||||
}
|
||||
if (typeof candidate['xtreams'] === 'string') {
|
||||
providers.xtreams = candidate['xtreams'];
|
||||
}
|
||||
if (typeof candidate['stalker'] === 'string') {
|
||||
providers.stalker = candidate['stalker'];
|
||||
}
|
||||
}
|
||||
|
||||
const parsedPlaylists = (parsed as Record<string, unknown>)[
|
||||
'playlists'
|
||||
];
|
||||
if (
|
||||
typeof parsedPlaylists === 'object' &&
|
||||
parsedPlaylists !== null
|
||||
) {
|
||||
Object.entries(
|
||||
parsedPlaylists as Record<string, unknown>
|
||||
).forEach(([playlistId, value]) => {
|
||||
if (!value || typeof value !== 'object' || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = value as Record<string, unknown>;
|
||||
const provider = row['provider'];
|
||||
const section = row['section'];
|
||||
const updatedAt = row['updatedAt'];
|
||||
const isProviderValid =
|
||||
provider === 'playlists' ||
|
||||
provider === 'xtreams' ||
|
||||
provider === 'stalker';
|
||||
if (!isProviderValid || typeof section !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
playlists[playlistId] = {
|
||||
provider,
|
||||
section,
|
||||
updatedAt:
|
||||
typeof updatedAt === 'number'
|
||||
? updatedAt
|
||||
: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { providers, playlists };
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
private writeSectionMemory(memory: PlaylistSectionMemory): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LAST_SECTION_STORAGE_KEY,
|
||||
JSON.stringify(memory)
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private removeDeletedPlaylistsFromSectionMemory(
|
||||
existingIds: string[]
|
||||
): void {
|
||||
const existingIdSet = new Set(existingIds);
|
||||
const memory = this.readSectionMemory();
|
||||
const currentEntries = Object.entries(memory.playlists);
|
||||
if (currentEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlaylists: PlaylistSectionMemory['playlists'] = {};
|
||||
let hasChanges = false;
|
||||
|
||||
currentEntries.forEach(([playlistId, entry]) => {
|
||||
if (!existingIdSet.has(playlistId)) {
|
||||
hasChanges = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSection = this.normalizeSectionForProvider(
|
||||
entry.section,
|
||||
entry.provider
|
||||
);
|
||||
if (!normalizedSection) {
|
||||
hasChanges = true;
|
||||
return;
|
||||
}
|
||||
|
||||
nextPlaylists[playlistId] = {
|
||||
...entry,
|
||||
section: normalizedSection,
|
||||
};
|
||||
});
|
||||
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.writeSectionMemory({
|
||||
providers: memory.providers,
|
||||
playlists: nextPlaylists,
|
||||
});
|
||||
}
|
||||
|
||||
private readPersistedActivePlaylistId(): string | null {
|
||||
try {
|
||||
const value = localStorage.getItem(
|
||||
LAST_ACTIVE_PLAYLIST_STORAGE_KEY
|
||||
);
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private writePersistedActivePlaylistId(playlistId: string): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY, playlistId);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private clearPersistedActivePlaylistId(): void {
|
||||
try {
|
||||
localStorage.removeItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private clearLegacySectionMemory(): void {
|
||||
try {
|
||||
if (!localStorage.getItem(LEGACY_LAST_SECTION_STORAGE_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(LEGACY_LAST_SECTION_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage read/write failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
|
||||
const M3U_EXTENSIONS = ['.m3u', '.m3u8'];
|
||||
|
||||
export type PlaylistFileImportResult =
|
||||
| { ok: true; title: string }
|
||||
| { ok: false; reason: 'unsupported' | 'empty' | 'read-error' };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistFileImportService {
|
||||
private readonly store = inject(Store);
|
||||
|
||||
isSupportedFile(file: File): boolean {
|
||||
const lower = file.name.toLowerCase();
|
||||
return M3U_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
async importFile(file: File): Promise<PlaylistFileImportResult> {
|
||||
if (!this.isSupportedFile(file)) {
|
||||
return { ok: false, reason: 'unsupported' };
|
||||
}
|
||||
|
||||
let playlist: string;
|
||||
try {
|
||||
playlist = await file.text();
|
||||
} catch {
|
||||
return { ok: false, reason: 'read-error' };
|
||||
}
|
||||
|
||||
if (!playlist.trim()) {
|
||||
return { ok: false, reason: 'empty' };
|
||||
}
|
||||
|
||||
const title = this.normalizeTitle(file.name);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.parsePlaylist({
|
||||
uploadType: 'FILE',
|
||||
playlist,
|
||||
title,
|
||||
path: (file as File & { path?: string }).path,
|
||||
})
|
||||
);
|
||||
|
||||
return { ok: true, title };
|
||||
}
|
||||
|
||||
private normalizeTitle(filename: string): string {
|
||||
const trimmed = filename.trim();
|
||||
if (!trimmed) {
|
||||
return filename;
|
||||
}
|
||||
return trimmed.replace(/\.(m3u8?|pls|txt)$/i, '') || trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export interface PlaylistPlayerActions {
|
||||
openSettings(): void;
|
||||
}
|
||||
|
||||
export const PLAYLIST_PLAYER_ACTIONS =
|
||||
new InjectionToken<PlaylistPlayerActions>('PLAYLIST_PLAYER_ACTIONS');
|
||||
@@ -0,0 +1,272 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
DatabaseService,
|
||||
PlaybackPositionService,
|
||||
PlaylistRefreshService,
|
||||
} from 'services';
|
||||
import { ChannelActions, PlaylistActions } from 'm3u-state';
|
||||
import { Playlist, PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistContextFacade } from './playlist-context.facade';
|
||||
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
|
||||
|
||||
function createPlaylistMeta(
|
||||
overrides: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: 'playlist-1',
|
||||
title: 'Xtream Playlist',
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('PlaylistRefreshActionService', () => {
|
||||
let service: PlaylistRefreshActionService;
|
||||
let databaseService: {
|
||||
createOperationId: jest.Mock;
|
||||
deleteXtreamPlaylistContent: jest.Mock;
|
||||
updateXtreamPlaylistDetails: jest.Mock;
|
||||
};
|
||||
let dialogService: {
|
||||
openConfirmDialog: jest.Mock;
|
||||
};
|
||||
let router: {
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: {
|
||||
dispatch: jest.Mock;
|
||||
};
|
||||
let playlistRefreshService: {
|
||||
refreshPlaylist: jest.Mock;
|
||||
};
|
||||
let playbackPositionService: {
|
||||
getAllPlaybackPositions: jest.Mock;
|
||||
};
|
||||
let routeProvider: ReturnType<
|
||||
typeof signal<'playlists' | 'xtreams' | null>
|
||||
>;
|
||||
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
databaseService = {
|
||||
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
|
||||
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
favorites: [
|
||||
{
|
||||
xtreamId: 101,
|
||||
contentType: 'live',
|
||||
},
|
||||
{
|
||||
xtreamId: 202,
|
||||
contentType: 'movie',
|
||||
},
|
||||
],
|
||||
recentlyViewed: [
|
||||
{
|
||||
xtreamId: 303,
|
||||
contentType: 'series',
|
||||
viewedAt: '2026-04-04T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
|
||||
}),
|
||||
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
router = {
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
playlistRefreshService = {
|
||||
refreshPlaylist: jest.fn(),
|
||||
};
|
||||
playbackPositionService = {
|
||||
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams');
|
||||
resolvedPlaylistId = signal<string | null>(null);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlaylistRefreshActionService,
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistRefreshService,
|
||||
useValue: playlistRefreshService,
|
||||
},
|
||||
{
|
||||
provide: PlaybackPositionService,
|
||||
useValue: playbackPositionService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
routeProvider,
|
||||
resolvedPlaylistId,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(PlaylistRefreshActionService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
|
||||
const item = createPlaylistMeta();
|
||||
const executionOrder: string[] = [];
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(1712217600000);
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
const setItemSpy = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(function (key: string, value: string) {
|
||||
executionOrder.push('setItem');
|
||||
return originalSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
store.dispatch.mockImplementation((action: unknown) => {
|
||||
executionOrder.push('dispatch');
|
||||
return action;
|
||||
});
|
||||
router.navigate.mockImplementation((commands: unknown[]) => {
|
||||
executionOrder.push('navigate');
|
||||
return Promise.resolve(Boolean(commands));
|
||||
});
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
|
||||
service.refresh(item);
|
||||
await confirmPromise;
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(
|
||||
`xtream-restore-${item._id}`,
|
||||
JSON.stringify({
|
||||
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
|
||||
favorites: [
|
||||
{
|
||||
xtreamId: 101,
|
||||
contentType: 'live',
|
||||
},
|
||||
{
|
||||
xtreamId: 202,
|
||||
contentType: 'movie',
|
||||
},
|
||||
],
|
||||
recentlyViewed: [
|
||||
{
|
||||
xtreamId: 303,
|
||||
contentType: 'series',
|
||||
viewedAt: '2026-04-04T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
playbackPositions: [],
|
||||
})
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate: 1712217600000 },
|
||||
})
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('marks the active M3U route as loading before refreshing and clears loading after the update action', async () => {
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-1',
|
||||
filePath: '/tmp/playlist.m3u',
|
||||
serverUrl: undefined,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
});
|
||||
const refreshedPlaylist = {
|
||||
_id: item._id,
|
||||
playlist: {
|
||||
items: [],
|
||||
},
|
||||
} as Playlist;
|
||||
routeProvider.set('playlists');
|
||||
resolvedPlaylistId.set(item._id);
|
||||
playlistRefreshService.refreshPlaylist.mockResolvedValue(
|
||||
refreshedPlaylist
|
||||
);
|
||||
|
||||
service.refresh(item);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.dispatch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
ChannelActions.setChannelsLoading({ loading: true })
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: refreshedPlaylist,
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
DatabaseService,
|
||||
isDbAbortError,
|
||||
PlaybackPositionService,
|
||||
PlaylistRefreshService,
|
||||
XtreamPendingRestoreService,
|
||||
} from 'services';
|
||||
import { ChannelActions, PlaylistActions } from 'm3u-state';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistContextFacade } from './playlist-context.facade';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistRefreshActionService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly playbackPositionService = inject(PlaybackPositionService);
|
||||
private readonly playlistRefreshService = inject(PlaylistRefreshService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly pendingRestoreService = inject(
|
||||
XtreamPendingRestoreService
|
||||
);
|
||||
|
||||
readonly isRefreshing = signal(false);
|
||||
|
||||
canRefresh(playlist: PlaylistMeta | null): boolean {
|
||||
if (!playlist || !window.electron) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(playlist.serverUrl || playlist.url || playlist.filePath);
|
||||
}
|
||||
|
||||
refresh(playlist: PlaylistMeta): void {
|
||||
if (this.isRefreshing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlist.serverUrl) {
|
||||
this.refreshXtream(playlist);
|
||||
} else if (playlist.url || playlist.filePath) {
|
||||
void this.refreshM3u(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshXtream(item: PlaylistMeta): void {
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
|
||||
),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: async () => {
|
||||
if (this.isRefreshing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRefreshing.set(true);
|
||||
const operationId =
|
||||
this.databaseService.createOperationId('xtream-refresh');
|
||||
|
||||
try {
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
|
||||
const updateDate = Date.now();
|
||||
const [restoreState, playbackPositions] = await Promise.all(
|
||||
[
|
||||
this.databaseService.deleteXtreamPlaylistContent(
|
||||
item._id,
|
||||
{ operationId }
|
||||
),
|
||||
this.playbackPositionService.getAllPlaybackPositions(
|
||||
item._id
|
||||
),
|
||||
this.databaseService.updateXtreamPlaylistDetails({
|
||||
id: item._id,
|
||||
updateDate,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
this.pendingRestoreService.set(item._id, {
|
||||
...restoreState,
|
||||
playbackPositions,
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
);
|
||||
|
||||
await this.router.navigate([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
console.error(
|
||||
'Error refreshing Xtream playlist:',
|
||||
error
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.isRefreshing.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshM3u(item: PlaylistMeta): Promise<void> {
|
||||
const isActiveM3uRoute =
|
||||
this.playlistContext.routeProvider() === 'playlists' &&
|
||||
this.playlistContext.resolvedPlaylistId() === item._id;
|
||||
|
||||
this.isRefreshing.set(true);
|
||||
if (isActiveM3uRoute) {
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannelsLoading({ loading: true })
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshedPlaylist =
|
||||
await this.playlistRefreshService.refreshPlaylist({
|
||||
operationId:
|
||||
this.databaseService.createOperationId(
|
||||
'playlist-refresh'
|
||||
),
|
||||
playlistId: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
filePath: item.filePath,
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: {
|
||||
...refreshedPlaylist,
|
||||
_id: item._id,
|
||||
},
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
console.error('Error refreshing playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.getRefreshErrorMessage(error, item),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
if (isActiveM3uRoute) {
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannelsLoading({ loading: false })
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.isRefreshing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private getRefreshErrorMessage(error: unknown, item: PlaylistMeta): string {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message?.includes('ENOENT') &&
|
||||
item.filePath
|
||||
) {
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user