refactor(downloads): isolate library navigation

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent bff9ca1491
commit ce251e5f9b
4 files changed
+624 -194

No files matched your search

@@ -0,0 +1,404 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import {
DatabaseService,
type DownloadItem,
PlaylistsService,
} from '@iptvnator/services';
import type { Playlist, StalkerPortalItem } from '@iptvnator/shared/interfaces';
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
const PLAYLIST_ID = 'playlist-a';
const XTREAM_PLAYLIST = {
_id: PLAYLIST_ID,
title: 'Xtream source',
importDate: '2026-07-30',
lastUsage: '2026-07-30',
count: 0,
autoRefresh: false,
portalUrl: 'https://xtream.example.test',
} satisfies Playlist;
const STALKER_PLAYLIST = {
...XTREAM_PLAYLIST,
title: 'Stalker source',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
} satisfies Playlist;
type RouterFake = jest.Mocked<Pick<Router, 'navigate'>>;
type DatabaseFake = jest.Mocked<
Pick<DatabaseService, 'getContentByXtreamId'>
>;
type PlaylistsFake = jest.Mocked<
Pick<
PlaylistsService,
'getPlaylistById' | 'getPortalRecentlyViewed'
>
>;
function download(overrides: Partial<DownloadItem> = {}): DownloadItem {
return {
id: 1,
playlistId: PLAYLIST_ID,
xtreamId: 41,
contentType: 'vod',
title: 'Downloaded title',
url: 'https://media.example.test/video',
posterUrl: 'https://media.example.test/poster.jpg',
status: 'completed',
...overrides,
};
}
describe('DownloadLibraryNavigationService', () => {
let navigation: DownloadLibraryNavigationService;
let router: RouterFake;
let db: DatabaseFake;
let playlists: PlaylistsFake;
beforeEach(() => {
router = {
navigate: jest.fn().mockResolvedValue(true),
};
db = {
getContentByXtreamId: jest.fn().mockResolvedValue(null),
};
playlists = {
getPlaylistById: jest.fn().mockReturnValue(of(XTREAM_PLAYLIST)),
getPortalRecentlyViewed: jest.fn().mockReturnValue(of([])),
};
TestBed.configureTestingModule({
providers: [
DownloadLibraryNavigationService,
{ provide: Router, useValue: router },
{ provide: DatabaseService, useValue: db },
{ provide: PlaylistsService, useValue: playlists },
],
});
navigation = TestBed.inject(DownloadLibraryNavigationService);
});
describe('canOpen', () => {
const availablePlaylistIds = new Set([PLAYLIST_ID]);
it('accepts positive safe VOD, series, and episode fallback IDs', () => {
expect(
navigation.canOpen(
download({ xtreamId: Number.MAX_SAFE_INTEGER }),
availablePlaylistIds
)
).toBe(true);
expect(
navigation.canOpen(
download({
contentType: 'episode',
xtreamId: 9,
seriesXtreamId: 72,
}),
availablePlaylistIds
)
).toBe(true);
expect(
navigation.canOpen(
download({
contentType: 'episode',
xtreamId: 9,
seriesXtreamId: undefined,
}),
availablePlaylistIds
)
).toBe(true);
});
it('rejects an item whose source playlist is absent', () => {
expect(navigation.canOpen(download(), new Set(['playlist-b']))).toBe(
false
);
});
it.each([
['missing', undefined as unknown as number],
['NaN', Number.NaN],
['positive infinity', Number.POSITIVE_INFINITY],
['negative infinity', Number.NEGATIVE_INFINITY],
['zero', 0],
['negative', -1],
['fractional', 4.5],
['unsafe', Number.MAX_SAFE_INTEGER + 1],
])('rejects a %s VOD target ID', (_label, xtreamId) => {
expect(
navigation.canOpen(
download({ xtreamId }),
availablePlaylistIds
)
).toBe(false);
});
it('does not fall back when an episode has a malformed series ID', () => {
expect(
navigation.canOpen(
download({
contentType: 'episode',
xtreamId: 9,
seriesXtreamId: 0,
}),
availablePlaylistIds
)
).toBe(false);
});
});
it.each([
['missing', undefined as unknown as number],
['zero', 0],
['negative', -8],
['fractional', 8.5],
['unsafe', Number.MAX_SAFE_INTEGER + 1],
])('does not resolve or navigate a %s target ID', async (_label, id) => {
await expect(navigation.open(download({ xtreamId: id }))).resolves.toBe(
false
);
expect(playlists.getPlaylistById).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('opens an Xtream VOD at its category detail route', async () => {
db.getContentByXtreamId.mockResolvedValue({
category_id: 7,
} as never);
await expect(navigation.open(download())).resolves.toBe(true);
expect(db.getContentByXtreamId).toHaveBeenCalledWith(41, PLAYLIST_ID);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
PLAYLIST_ID,
'vod',
'7',
'41',
]);
});
it('opens an Xtream episode at its series detail route', async () => {
const item = download({
contentType: 'episode',
xtreamId: 511,
seriesXtreamId: 93,
});
db.getContentByXtreamId.mockResolvedValue({
category_id: '12',
} as never);
await expect(navigation.open(item)).resolves.toBe(true);
expect(db.getContentByXtreamId).toHaveBeenCalledWith(93, PLAYLIST_ID);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
PLAYLIST_ID,
'series',
'12',
'93',
]);
});
it.each([
['missing', {}],
['null', { category_id: null }],
])(
'falls back to the Xtream collection route for a %s category',
async (_label, content) => {
db.getContentByXtreamId.mockResolvedValue(content as never);
await expect(navigation.open(download())).resolves.toBe(true);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
PLAYLIST_ID,
'vod',
]);
}
);
it('returns false when the source playlist is missing', async () => {
playlists.getPlaylistById.mockReturnValue(
of(undefined as unknown as Playlist)
);
await expect(navigation.open(download())).resolves.toBe(false);
expect(db.getContentByXtreamId).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('returns false when the source playlist lookup fails', async () => {
playlists.getPlaylistById.mockReturnValue(
throwError(() => new Error('lookup failed'))
);
await expect(navigation.open(download())).resolves.toBe(false);
expect(db.getContentByXtreamId).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it.each(['id', 'movie_id', 'series_id', 'stream_id'] as const)(
'matches a Stalker recent item by normalized %s',
async (idKey) => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
const recent = {
[idKey]: '41:archive',
category_id: 'series',
title: 'Recent title',
name: 'Recent name',
preserved: 'yes',
} satisfies StalkerPortalItem & { preserved: string };
playlists.getPortalRecentlyViewed.mockReturnValue(of([recent]));
await expect(navigation.open(download())).resolves.toBe(true);
const expectedId =
idKey === 'stream_id' ? '41' : recent[idKey];
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'stalker', PLAYLIST_ID, 'recent'],
{
state: {
openRecentItem: {
...recent,
id: expectedId,
category_id: 'series',
title: 'Recent title',
name: 'Recent name',
},
},
}
);
}
);
it('preserves a matched Stalker item and fills its normalized display fields', async () => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
const recent = {
series_id: '41:episode',
category_id: 'movie',
o_name: 'Portal original name',
preserved: { token: 1 },
} satisfies StalkerPortalItem & {
preserved: { token: number };
};
playlists.getPortalRecentlyViewed.mockReturnValue(of([recent]));
await expect(navigation.open(download())).resolves.toBe(true);
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'stalker', PLAYLIST_ID, 'recent'],
{
state: {
openRecentItem: {
...recent,
id: '41:episode',
category_id: 'vod',
title: 'Downloaded title',
name: 'Portal original name',
},
},
}
);
});
it.each([
['VOD', 'vod', 'vod'],
['SERIES', 'series', 'vod'],
['ItV', 'itv', 'vod'],
['radio', 'vod', 'vod'],
['unknown', 'series', 'episode'],
] as const)(
'normalizes Stalker category %s to %s',
async (categoryId, expected, contentType) => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
playlists.getPortalRecentlyViewed.mockReturnValue(
of([
{
id: '41',
category_id: categoryId,
},
])
);
await navigation.open(download({ contentType }));
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'stalker', PLAYLIST_ID, 'recent'],
{
state: {
openRecentItem: expect.objectContaining({
category_id: expected,
}),
},
}
);
}
);
it('uses download metadata when no Stalker recent item matches', async () => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
playlists.getPortalRecentlyViewed.mockReturnValue(
of([{ id: '999', category_id: 'vod' }])
);
await expect(navigation.open(download())).resolves.toBe(true);
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'stalker', PLAYLIST_ID, 'recent'],
{
state: {
openRecentItem: {
id: '41',
category_id: 'vod',
title: 'Downloaded title',
name: 'Downloaded title',
o_name: 'Downloaded title',
cover: 'https://media.example.test/poster.jpg',
logo: 'https://media.example.test/poster.jpg',
},
},
}
);
});
it('uses episode download metadata when Stalker recent loading fails', async () => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
playlists.getPortalRecentlyViewed.mockReturnValue(
throwError(() => new Error('recent failed'))
);
const item = download({
contentType: 'episode',
xtreamId: 501,
seriesXtreamId: 72,
});
await expect(navigation.open(item)).resolves.toBe(true);
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'stalker', PLAYLIST_ID, 'recent'],
{
state: {
openRecentItem: {
id: '72',
category_id: 'series',
title: 'Downloaded title',
name: 'Downloaded title',
o_name: 'Downloaded title',
cover: 'https://media.example.test/poster.jpg',
logo: 'https://media.example.test/poster.jpg',
},
},
}
);
});
});
@@ -0,0 +1,210 @@
import { inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
DatabaseService,
type DownloadItem,
PlaylistsService,
} from '@iptvnator/services';
import type { StalkerPortalItem } from '@iptvnator/shared/interfaces';
type PortalSource = 'xtream' | 'stalker';
type StalkerCategory = 'vod' | 'series' | 'itv';
type StalkerFallbackCategory = Exclude<StalkerCategory, 'itv'>;
@Injectable()
export class DownloadLibraryNavigationService {
private readonly router = inject(Router);
private readonly db = inject(DatabaseService);
private readonly playlists = inject(PlaylistsService);
canOpen(
item: DownloadItem,
availablePlaylistIds: ReadonlySet<string>
): boolean {
return (
availablePlaylistIds.has(item.playlistId) &&
this.targetId(item) !== null
);
}
async open(item: DownloadItem): Promise<boolean> {
const targetId = this.targetId(item);
if (targetId === null) {
return false;
}
const source = await this.resolveSourceType(item.playlistId);
if (source === null) {
return false;
}
if (source === 'xtream') {
await this.openXtreamItem(item, targetId);
} else {
await this.openStalkerItem(item, targetId);
}
return true;
}
private targetId(item: DownloadItem): number | null {
const id =
item.contentType === 'episode'
? (item.seriesXtreamId ?? item.xtreamId)
: item.xtreamId;
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
private route(
source: PortalSource,
playlistId: string,
segments: Array<string | number>
): Array<string | number> {
return [
'/workspace',
source === 'stalker' ? 'stalker' : 'xtreams',
playlistId,
...segments,
];
}
private async resolveSourceType(
playlistId: string
): Promise<PortalSource | null> {
try {
const playlist = await firstValueFrom(
this.playlists.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
return playlist.portalUrl && playlist.macAddress
? 'stalker'
: 'xtream';
} catch {
return null;
}
}
private async openXtreamItem(
item: DownloadItem,
targetId: number
): Promise<void> {
const contentType =
item.contentType === 'episode' ? 'series' : 'vod';
const content = await this.db.getContentByXtreamId(
targetId,
item.playlistId
);
const categoryId = content?.category_id;
const segments =
categoryId === null || categoryId === undefined
? [contentType]
: [contentType, String(categoryId), String(targetId)];
await this.router.navigate(
this.route('xtream', item.playlistId, segments)
);
}
private normalizePortalItemId(value: unknown): string {
const raw = String(value ?? '').trim();
return raw.includes(':') ? raw.split(':')[0] : raw;
}
private stalkerCategory(
value: unknown,
fallback: StalkerFallbackCategory
): StalkerCategory {
const normalized = String(value ?? '').toLowerCase();
if (normalized === 'movie') {
return 'vod';
}
return normalized === 'vod' ||
normalized === 'series' ||
normalized === 'itv'
? normalized
: fallback;
}
private findMatchingStalkerRecentItem(
items: readonly StalkerPortalItem[],
targetId: number
): StalkerPortalItem | undefined {
const expectedId = String(targetId);
return items.find((item) =>
[item.id, item.movie_id, item.series_id, item.stream_id].some(
(candidate) =>
this.normalizePortalItemId(candidate) === expectedId
)
);
}
private async stalkerOpenState(
item: DownloadItem,
targetId: number,
fallback: StalkerFallbackCategory
): Promise<Record<string, unknown>> {
try {
const recent = await firstValueFrom(
this.playlists.getPortalRecentlyViewed(item.playlistId)
);
const matched = this.findMatchingStalkerRecentItem(
recent,
targetId
);
if (matched) {
return {
...matched,
id:
matched.id ??
matched.series_id ??
matched.movie_id ??
String(targetId),
category_id: this.stalkerCategory(
matched.category_id,
fallback
),
title: matched.title ?? item.title,
name: matched.name ?? matched.o_name ?? item.title,
};
}
} catch {
// Persisted download metadata is enough to open the recent route.
}
return {
id: String(targetId),
category_id: fallback,
title: item.title,
name: item.title,
o_name: item.title,
cover: item.posterUrl,
logo: item.posterUrl,
};
}
private async openStalkerItem(
item: DownloadItem,
targetId: number
): Promise<void> {
const fallback =
item.contentType === 'episode' ? 'series' : 'vod';
const openRecentItem = await this.stalkerOpenState(
item,
targetId,
fallback
);
await this.router.navigate(
this.route('stalker', item.playlistId, ['recent']),
{ state: { openRecentItem } }
);
}
}
@@ -15,9 +15,8 @@ import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import { firstValueFrom, map, startWith } from 'rxjs';
import { map, startWith } from 'rxjs';
import {
DatabaseService,
type DownloadItem,
DownloadsService,
PlaylistsService,
@@ -32,8 +31,8 @@ import {
} from '@iptvnator/portal/shared/util';
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
import { Playlist } from '@iptvnator/shared/interfaces';
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
type PortalSource = 'xtream' | 'stalker';
const DOWNLOAD_COLLECTION_LABELS = {
all: 'All',
movie: 'Movies',
@@ -49,6 +48,7 @@ const DOWNLOAD_COLLECTION_LABELS = {
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [DownloadLibraryNavigationService],
imports: [
EmptyStateComponent,
MatButtonModule,
@@ -61,8 +61,10 @@ const DOWNLOAD_COLLECTION_LABELS = {
export class DownloadsComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly libraryNavigation = inject(
DownloadLibraryNavigationService
);
private readonly collectionCtx = inject(PortalCollectionContextService);
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
@@ -373,10 +375,9 @@ export class DownloadsComponent {
}
isItemNavigable(item: DownloadItem): boolean {
return (
!!item.playlistId &&
this.hasSourcePlaylist(item) &&
this.getTargetContentId(item) !== null
return this.libraryNavigation.canOpen(
item,
this.availablePlaylistIds()
);
}
@@ -407,191 +408,7 @@ export class DownloadsComponent {
return;
}
const source = await this.resolveSourceType(item.playlistId);
if (!source) {
return;
}
if (source === 'xtream') {
await this.openXtreamItem(item);
return;
}
await this.openStalkerItem(item);
}
private getTargetContentId(item: DownloadItem): number | null {
const id =
item.contentType === 'episode'
? (item.seriesXtreamId ?? item.xtreamId)
: item.xtreamId;
const numeric = Number(id);
return Number.isFinite(numeric) ? numeric : null;
}
private buildPlaylistRoute(
source: PortalSource,
playlistId: string,
segments: Array<string | number>
): Array<string | number> {
const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams';
return ['/workspace', sourceSegment, playlistId, ...segments];
}
private async resolveSourceType(
playlistId: string
): Promise<PortalSource | null> {
try {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
if (playlist.portalUrl && playlist.macAddress) {
return 'stalker';
}
return 'xtream';
} catch {
return null;
}
}
private async openXtreamItem(item: DownloadItem): Promise<void> {
const targetId = this.getTargetContentId(item);
if (targetId === null) return;
const contentType = item.contentType === 'episode' ? 'series' : 'vod';
const content = await this.dbService.getContentByXtreamId(
targetId,
item.playlistId
);
const categoryId = content?.category_id;
if (categoryId === null || categoryId === undefined) {
await this.router.navigate(
this.buildPlaylistRoute('xtream', item.playlistId, [
contentType,
])
);
return;
}
await this.router.navigate(
this.buildPlaylistRoute('xtream', item.playlistId, [
contentType,
String(categoryId),
String(targetId),
])
);
}
private normalizePortalItemId(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.includes(':') ? raw.split(':')[0] : raw;
}
private normalizeStalkerCategoryId(
value: unknown,
fallback: 'vod' | 'series'
): 'vod' | 'series' | 'itv' {
const normalized = String(value ?? '').toLowerCase();
if (normalized === 'movie') return 'vod';
if (
normalized === 'vod' ||
normalized === 'series' ||
normalized === 'itv'
) {
return normalized;
}
return fallback;
}
private findMatchingStalkerRecentItem(
items: Array<Record<string, unknown>>,
targetId: number
): Record<string, unknown> | undefined {
const expectedId = String(targetId);
return items.find((recentItem) => {
const candidates = [
recentItem['id'],
recentItem['movie_id'],
recentItem['series_id'],
recentItem['stream_id'],
];
return candidates.some(
(candidate) =>
this.normalizePortalItemId(candidate) === expectedId
);
});
}
private async buildStalkerOpenState(
item: DownloadItem,
targetId: number,
fallbackCategory: 'vod' | 'series'
): Promise<Record<string, unknown>> {
try {
const items = (await firstValueFrom(
this.playlistsService.getPortalRecentlyViewed(item.playlistId)
)) as Array<Record<string, unknown>>;
const matched = this.findMatchingStalkerRecentItem(items, targetId);
if (matched) {
return {
...matched,
id:
matched['id'] ??
matched['series_id'] ??
matched['movie_id'] ??
String(targetId),
category_id: this.normalizeStalkerCategoryId(
matched['category_id'],
fallbackCategory
),
title: matched['title'] ?? item.title,
name: matched['name'] ?? matched['o_name'] ?? item.title,
};
}
} catch {
// Ignore and use fallback state below.
}
return {
id: String(targetId),
category_id: fallbackCategory,
title: item.title,
name: item.title,
o_name: item.title,
cover: item.posterUrl,
logo: item.posterUrl,
};
}
private async openStalkerItem(item: DownloadItem): Promise<void> {
const targetId = this.getTargetContentId(item);
if (targetId === null) return;
const fallbackCategory =
item.contentType === 'episode' ? 'series' : 'vod';
const openRecentItem = await this.buildStalkerOpenState(
item,
targetId,
fallbackCategory
);
await this.router.navigate(
this.buildPlaylistRoute('stalker', item.playlistId, ['recent']),
{
state: { openRecentItem },
}
);
await this.libraryNavigation.open(item);
}
private getPosterKey(item: DownloadItem): string {
-1
View File
@@ -32,7 +32,6 @@ export const maxLinesBaseline = [
'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts',
'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts',
'libs/playlist/shared/util/src/lib/playlist-context.facade.ts',
'libs/portal/downloads/feature/src/lib/downloads.component.ts',
'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts',