Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+21
View File
@@ -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',
],
};
+21
View File
@@ -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"
}
}
}
+4
View File
@@ -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,
});
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -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"
]
}