mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
refactor(downloads): isolate library navigation
This commit is contained in:
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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user