mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat: enhance M3U collection route component with playlist ID handling and add tests for playlist switcher and context facade
This commit is contained in:
1 parent
d5f20d1df2
commit
4e74ac2546
4 files changed
+705
No files matched your search
+11
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { routeParamSignal } from '@iptvnator/portal/shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-m3u-collection-route',
|
||||
@@ -12,11 +15,19 @@ import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
|
||||
<app-unified-collection-page
|
||||
[mode]="mode()"
|
||||
[portalType]="portalType()"
|
||||
[playlistId]="playlistId()"
|
||||
/>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class M3uCollectionRouteComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly portalType = input('m3u');
|
||||
readonly playlistId = routeParamSignal<string | undefined>(
|
||||
this.route,
|
||||
'id',
|
||||
(value) => value ?? undefined
|
||||
);
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
import { signal } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
flushMicrotasks,
|
||||
tick,
|
||||
} from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PortalStatusService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
|
||||
|
||||
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
|
||||
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
|
||||
|
||||
function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
filename: overrides.filename,
|
||||
count: overrides.count ?? 0,
|
||||
importDate:
|
||||
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
autoRefresh: overrides.autoRefresh ?? false,
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('PlaylistSwitcherComponent', () => {
|
||||
let fixture: ComponentFixture<PlaylistSwitcherComponent>;
|
||||
let component: PlaylistSwitcherComponent;
|
||||
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
|
||||
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
|
||||
let playlistContext: {
|
||||
playlists: typeof playlistsSignal;
|
||||
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
|
||||
resolvedPlaylistId: typeof resolvedPlaylistIdSignal;
|
||||
activePlaylist: typeof activePlaylistSignal;
|
||||
selectPlaylist: jest.Mock;
|
||||
};
|
||||
let portalStatusService: {
|
||||
checkPortalStatus: jest.Mock;
|
||||
getStatusClass: jest.Mock;
|
||||
};
|
||||
|
||||
const m3uPlaylist = createPlaylist({
|
||||
_id: 'm3u-1',
|
||||
count: 24,
|
||||
importDate: '2026-04-05T09:00:00.000Z',
|
||||
title: 'Morning M3U',
|
||||
url: 'http://example.test/m3u.m3u',
|
||||
});
|
||||
const stalkerPlaylist = createPlaylist({
|
||||
_id: 'stalker-1',
|
||||
importDate: '2026-04-05T11:00:00.000Z',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Living Room Stalker',
|
||||
});
|
||||
const xtreamPlaylist = createPlaylist({
|
||||
_id: 'xtream-1',
|
||||
importDate: '2026-04-05T12:00:00.000Z',
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Cinema Xtream',
|
||||
updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(),
|
||||
username: 'user1',
|
||||
});
|
||||
|
||||
async function createComponent(): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlaylistSwitcherComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: playlistContext,
|
||||
},
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: portalStatusService,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PlaylistSwitcherComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('currentTitle', '');
|
||||
fixture.componentRef.setInput('subtitle', '3 playlists');
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
playlistsSignal = signal([m3uPlaylist, stalkerPlaylist, xtreamPlaylist]);
|
||||
resolvedPlaylistIdSignal = signal<string | null>(xtreamPlaylist._id);
|
||||
activePlaylistSignal = signal<PlaylistMeta | null>(xtreamPlaylist);
|
||||
|
||||
playlistContext = {
|
||||
playlists: playlistsSignal,
|
||||
allPlaylistsLoaded: signal(true),
|
||||
resolvedPlaylistId: resolvedPlaylistIdSignal,
|
||||
activePlaylist: activePlaylistSignal,
|
||||
selectPlaylist: jest.fn((playlist: PlaylistMeta) => {
|
||||
resolvedPlaylistIdSignal.set(playlist._id);
|
||||
activePlaylistSignal.set(playlist);
|
||||
}),
|
||||
};
|
||||
portalStatusService = {
|
||||
checkPortalStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('active')
|
||||
.mockResolvedValueOnce('inactive'),
|
||||
getStatusClass: jest.fn((status: string) => `status-${status}`),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads persisted search and type filters on startup and filters playlists accordingly', async () => {
|
||||
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room');
|
||||
localStorage.setItem(
|
||||
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
m3u: false,
|
||||
stalker: true,
|
||||
xtream: true,
|
||||
})
|
||||
);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.searchQuery()).toBe('room');
|
||||
expect(component.isTypeFilterSelected('m3u')).toBe(false);
|
||||
expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]);
|
||||
});
|
||||
|
||||
it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(component.filteredPlaylists().map((playlist) => playlist._id)).toEqual([
|
||||
xtreamPlaylist._id,
|
||||
stalkerPlaylist._id,
|
||||
m3uPlaylist._id,
|
||||
]);
|
||||
|
||||
component.togglePlaylistTypeFilter('m3u');
|
||||
component.togglePlaylistTypeFilter('stalker');
|
||||
expect(component.playlistTypeFilters()).toEqual({
|
||||
m3u: false,
|
||||
stalker: false,
|
||||
xtream: true,
|
||||
});
|
||||
|
||||
component.togglePlaylistTypeFilter('xtream');
|
||||
expect(component.playlistTypeFilters()).toEqual({
|
||||
m3u: false,
|
||||
stalker: false,
|
||||
xtream: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects a playlist, closes the menu, and emits the selected id', async () => {
|
||||
await createComponent();
|
||||
|
||||
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
|
||||
const selectedIds: string[] = [];
|
||||
component.playlistSelected.subscribe((playlistId) => {
|
||||
selectedIds.push(playlistId);
|
||||
});
|
||||
|
||||
component.selectPlaylist(stalkerPlaylist);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(closeMenuSpy).toHaveBeenCalledTimes(1);
|
||||
expect(playlistContext.selectPlaylist).toHaveBeenCalledWith(
|
||||
stalkerPlaylist
|
||||
);
|
||||
expect(selectedIds).toEqual([stalkerPlaylist._id]);
|
||||
expect(component.isSelected(stalkerPlaylist)).toBe(true);
|
||||
});
|
||||
|
||||
it('emits playlist and account info requests through the context actions', async () => {
|
||||
await createComponent();
|
||||
|
||||
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
|
||||
const playlistInfoSpy = jest.fn();
|
||||
const accountInfoSpy = jest.fn();
|
||||
component.playlistInfoRequested.subscribe(playlistInfoSpy);
|
||||
component.accountInfoRequested.subscribe(accountInfoSpy);
|
||||
|
||||
component.requestPlaylistInfo();
|
||||
component.requestAccountInfo();
|
||||
|
||||
expect(closeMenuSpy).toHaveBeenCalledTimes(2);
|
||||
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
|
||||
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
|
||||
await createComponent();
|
||||
|
||||
const triggerElement = component.triggerElement().nativeElement;
|
||||
jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 320,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: 320,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
component.onMenuOpened();
|
||||
flushMicrotasks();
|
||||
tick();
|
||||
|
||||
expect(component.isMenuOpen()).toBe(true);
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--playlist-switcher-overlay-width'
|
||||
)
|
||||
).toBe('320px');
|
||||
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1);
|
||||
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
|
||||
xtreamPlaylist.serverUrl,
|
||||
xtreamPlaylist.username,
|
||||
xtreamPlaylist.password
|
||||
);
|
||||
expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe('active');
|
||||
expect(component.getStatusClass(xtreamPlaylist._id)).toBe('status-active');
|
||||
|
||||
component.onMenuClosed();
|
||||
expect(component.isMenuOpen()).toBe(false);
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--playlist-switcher-overlay-width'
|
||||
)
|
||||
).toBe('');
|
||||
}));
|
||||
|
||||
it('falls back to the active playlist title when no explicit current title is provided', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(component.displayTitle()).toBe('Cinema Xtream');
|
||||
|
||||
resolvedPlaylistIdSignal.set(null);
|
||||
activePlaylistSignal.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.displayTitle()).toBe('Select playlist');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -312,6 +312,10 @@ export class PlaylistContextFacade {
|
||||
return fromCurrent;
|
||||
}
|
||||
|
||||
if (provider === 'playlists' && currentSection) {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const memory = this.readSectionMemory();
|
||||
const playlistMemory = memory.playlists[targetPlaylistId];
|
||||
if (playlistMemory?.provider === provider) {
|
||||
|
||||
Reference in new issue
Block a user