feat(downloads): open ready cards in offline details

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent e8e2a31312
commit 1ec0f444e4
6 files changed
+148 -142

No files matched your search

@@ -24,25 +24,10 @@
class="download-library__artwork-button"
data-test-id="download-library-series-open"
[disabled]="isPending(entity.representative)"
[class.download-library__artwork-button--disabled]="
!canOpen(entity.representative)
"
[attr.aria-disabled]="
!canOpen(entity.representative)
? 'true'
: null
"
[attr.aria-label]="
'DOWNLOADS.OPEN_ARTWORK'
| translate: { title: entity.title }
"
[matTooltip]="
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate
"
[matTooltipDisabled]="
canOpen(entity.representative)
"
(click)="openDetails(entity.representative)"
>
@if (artworkUrl(entity); as artwork) {
@@ -86,26 +71,11 @@
[disabled]="
isPending(entity.representative)
"
[class.download-library__title-button--disabled]="
!canOpen(entity.representative)
"
[attr.aria-disabled]="
!canOpen(entity.representative)
? 'true'
: null
"
[attr.aria-label]="
('DOWNLOADS.OPEN_DETAILS' | translate) +
': ' +
entity.title
"
[matTooltip]="
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate
"
[matTooltipDisabled]="
canOpen(entity.representative)
"
(click)="openDetails(entity.representative)"
>
{{ entity.title }}
@@ -227,16 +197,6 @@
type="button"
class="download-library__artwork-button"
[disabled]="isPending(entity.item)"
[class.download-library__artwork-button--disabled]="
entity.kind === 'movie' &&
!canOpen(entity.item)
"
[attr.aria-disabled]="
entity.kind === 'movie' &&
!canOpen(entity.item)
? 'true'
: null
"
[attr.aria-label]="
entity.kind === 'movie'
? ('DOWNLOADS.OPEN_ARTWORK'
@@ -246,14 +206,6 @@
': ' +
entity.item.title
"
[matTooltip]="
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate
"
[matTooltipDisabled]="
entity.kind !== 'movie' ||
canOpen(entity.item)
"
(click)="
entity.kind === 'movie'
? openDetails(entity.item)
@@ -306,16 +258,6 @@
type="button"
class="download-library__title-button"
[disabled]="isPending(entity.item)"
[class.download-library__title-button--disabled]="
entity.kind === 'movie' &&
!canOpen(entity.item)
"
[attr.aria-disabled]="
entity.kind === 'movie' &&
!canOpen(entity.item)
? 'true'
: null
"
[attr.aria-label]="
((entity.kind === 'movie'
? 'DOWNLOADS.OPEN_DETAILS'
@@ -324,14 +266,6 @@
': ' +
entity.item.title
"
[matTooltip]="
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate
"
[matTooltipDisabled]="
entity.kind !== 'movie' ||
canOpen(entity.item)
"
(click)="
entity.kind === 'movie'
? openDetails(entity.item)
@@ -1,9 +1,6 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { FocusMonitor } from '@angular/cdk/a11y';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatTooltipHarness } from '@angular/material/tooltip/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import type { DownloadItem } from '@iptvnator/services';
@@ -33,8 +30,6 @@ const TRANSLATIONS = {
SEASON: 'Season {{season}}',
SEASON_RANGE: 'Seasons {{first}}–{{last}}',
SERIES: 'Series',
SOURCE_PLAYLIST_MISSING:
'This download can no longer open because its source was removed.',
},
PORTALS: {
MULTI_SOURCE: {
@@ -154,10 +149,6 @@ describe('DownloadLibraryComponent', () => {
fixture = TestBed.createComponent(DownloadLibraryComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('entities', ENTITIES);
fixture.componentRef.setInput(
'availablePlaylistIds',
new Set(['playlist-a'])
);
await fixture.whenStable();
});
@@ -323,9 +314,7 @@ describe('DownloadLibraryComponent', () => {
expect(actions).toEqual([]);
});
it('keeps orphaned movie navigation focusable and explains why it cannot open', async () => {
fixture.componentRef.setInput('availablePlaylistIds', new Set());
await fixture.whenStable();
it('opens orphaned movie details without source-dependent disabled semantics', async () => {
const card = byTestId('download-library-movie-9');
const artwork = button(card, 'Open details: Moonrise artwork');
const title = button(card, 'Open details: Moonrise');
@@ -334,27 +323,12 @@ describe('DownloadLibraryComponent', () => {
expect(artwork.disabled).toBe(false);
expect(title.disabled).toBe(false);
expect(artwork.getAttribute('aria-disabled')).toBe('true');
expect(title.getAttribute('aria-disabled')).toBe('true');
const loader = TestbedHarnessEnvironment.loader(fixture);
const tooltip = await loader.getHarness(
MatTooltipHarness.with({
selector:
'.download-library__artwork-button[aria-label="Open details: Moonrise artwork"]',
})
);
TestBed.inject(FocusMonitor).focusVia(artwork, 'keyboard');
await fixture.whenStable();
expect(await tooltip.isOpen()).toBe(true);
expect(await tooltip.getTooltipText()).toBe(
'This download can no longer open because its source was removed.'
);
await tooltip.hide();
expect(artwork.getAttribute('aria-disabled')).toBeNull();
expect(title.getAttribute('aria-disabled')).toBeNull();
await click(artwork);
await click(title);
expect(opened).toEqual([]);
expect(opened).toEqual([MOVIE, MOVIE]);
});
it('keeps explicit movie toolbar and menu commands local', async () => {
@@ -430,13 +404,19 @@ describe('DownloadLibraryComponent', () => {
await fixture.whenStable();
expect(opened).toEqual([]);
await click(
button(
byTestId('download-library-movie-9'),
'Open details: Moonrise artwork'
)
);
expect(opened).toEqual([MOVIE]);
await click(episodes);
expect(episodeGroups).toEqual([SERIES]);
});
it('disables orphaned series navigation and explains why while keeping episodes local', async () => {
fixture.componentRef.setInput('availablePlaylistIds', new Set());
await fixture.whenStable();
it('opens orphaned series navigation while keeping episodes local', async () => {
const card = byTestId('download-library-series-playlist-a-77');
const artwork = button(card, 'Open details: Northwind artwork');
const title = button(card, 'Open details: Northwind');
@@ -446,29 +426,16 @@ describe('DownloadLibraryComponent', () => {
expect(artwork.disabled).toBe(false);
expect(title.disabled).toBe(false);
expect(artwork.getAttribute('aria-disabled')).toBe('true');
expect(title.getAttribute('aria-disabled')).toBe('true');
expect(artwork.getAttribute('aria-disabled')).toBeNull();
expect(title.getAttribute('aria-disabled')).toBeNull();
expect(episodes.disabled).toBe(false);
const loader = TestbedHarnessEnvironment.loader(fixture);
const tooltip = await loader.getHarness(
MatTooltipHarness.with({
selector:
'[data-test-id="download-library-series-open"].download-library__artwork-button',
})
);
TestBed.inject(FocusMonitor).focusVia(artwork, 'keyboard');
await fixture.whenStable();
artwork.focus();
expect(document.activeElement).toBe(artwork);
expect(await tooltip.isOpen()).toBe(true);
expect(await tooltip.getTooltipText()).toBe(
'This download can no longer open because its source was removed.'
);
await tooltip.hide();
await click(artwork);
await click(title);
expect(opened).toEqual([]);
expect(opened).toEqual([SERIES.representative, SERIES.representative]);
});
it('opens the downloaded episode group from its count control', async () => {
@@ -38,7 +38,6 @@ import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header
})
export class DownloadLibraryComponent {
readonly entities = input.required<readonly DownloadLibraryEntity[]>();
readonly availablePlaylistIds = input.required<ReadonlySet<string>>();
readonly pendingIds = input<ReadonlySet<number>>(new Set());
readonly itemAction = output<DownloadItemAction>();
readonly openRequested = output<DownloadItem>();
@@ -83,10 +82,6 @@ export class DownloadLibraryComponent {
return this.pendingIds().has(item.id);
}
protected canOpen(item: DownloadItem): boolean {
return this.availablePlaylistIds().has(item.playlistId);
}
protected emitAction(
type: DownloadItemActionType,
item: DownloadItem
@@ -97,7 +92,7 @@ export class DownloadLibraryComponent {
}
protected openDetails(item: DownloadItem): void {
if (!this.isPending(item) && this.canOpen(item)) {
if (!this.isPending(item)) {
this.openRequested.emit(item);
}
}
@@ -232,10 +232,9 @@
/>
<app-download-library
[entities]="model().library"
[availablePlaylistIds]="availablePlaylistIds()"
[pendingIds]="pendingIds()"
(itemAction)="runAction($event)"
(openRequested)="openInLibrary($event)"
(openRequested)="openOfflineDetail($event)"
(episodesOpened)="openDownloadedSeries($event)"
/>
}
@@ -5,7 +5,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import {
ActivatedRoute,
convertToParamMap,
type ParamMap,
Router,
} from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
type DownloadItem,
@@ -18,7 +23,7 @@ import {
} from '@iptvnator/portal/shared/util';
import type { Playlist } from '@iptvnator/shared/interfaces';
import { DialogService } from '@iptvnator/ui/components';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, type Observable, Subject } from 'rxjs';
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
import { DownloadManagerActionsService } from './download-manager-actions.service';
import type { DownloadSeriesCardViewModel } from './download-manager.viewmodel';
@@ -44,6 +49,7 @@ interface ExpectedDownloadsComponent {
readonly pendingIds: () => ReadonlySet<number>;
clearFinished(): void;
openDownloadedSeries(group: DownloadSeriesCardViewModel): void;
openOfflineDetail(item: DownloadItem): void;
openInLibrary(item: DownloadItem): Promise<void>;
runAction(action: {
readonly type: string;
@@ -90,6 +96,16 @@ describe('DownloadsComponent', () => {
let downloads: ReturnType<typeof signal<DownloadItem[]>>;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let queryParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let activatedRoute: {
readonly paramMap: Observable<ParamMap>;
readonly queryParamMap: Observable<ParamMap>;
readonly snapshot: {
readonly params: Record<string, string>;
readonly paramMap: ReturnType<typeof convertToParamMap>;
readonly queryParamMap: ReturnType<typeof convertToParamMap>;
};
};
let router: { url: string; navigate: jest.Mock };
let playlistItems: BehaviorSubject<Playlist[]>;
let collectionContext: PortalCollectionContextService;
let dialogConfigs: ConfirmConfig[];
@@ -131,6 +147,19 @@ describe('DownloadsComponent', () => {
downloads = signal<DownloadItem[]>([]);
routeParams = new BehaviorSubject(convertToParamMap({}));
queryParams = new BehaviorSubject(convertToParamMap({}));
activatedRoute = {
paramMap: routeParams.asObservable(),
queryParamMap: queryParams.asObservable(),
snapshot: {
params: {},
paramMap: convertToParamMap({}),
queryParamMap: convertToParamMap({}),
},
};
router = {
url: '/workspace/downloads?q=signal',
navigate: jest.fn(async () => true),
};
playlistItems = new BehaviorSubject([
playlist('playlist-a', 'Alpha source'),
playlist('playlist-b', 'Beta source'),
@@ -204,19 +233,11 @@ describe('DownloadsComponent', () => {
providers: [
{
provide: ActivatedRoute,
useValue: {
paramMap: routeParams.asObservable(),
queryParamMap: queryParams.asObservable(),
snapshot: {
params: {},
paramMap: convertToParamMap({}),
queryParamMap: convertToParamMap({}),
},
},
useValue: activatedRoute,
},
{
provide: Router,
useValue: { navigate: jest.fn(async () => true) },
useValue: router,
},
{
provide: DownloadsService,
@@ -718,10 +739,11 @@ describe('DownloadsComponent', () => {
);
});
it('opens completed movie artwork in details while explicit Play uses the file', async () => {
const item = download(14, {
title: 'Completed movie',
filePath: '/downloads/completed-movie.mp4',
it('opens removed-source movie artwork in its offline detail while explicit Play stays local', async () => {
const item = download(17, {
playlistId: 'removed-playlist',
title: 'Signal',
filePath: '/downloads/signal.mp4',
});
downloads.set([item]);
await fixture.whenStable();
@@ -734,12 +756,17 @@ describe('DownloadsComponent', () => {
artworkButton.click();
await fixture.whenStable();
expect(navigation.open).toHaveBeenCalledWith(item);
expect(router.navigate).toHaveBeenCalledWith(['17'], {
relativeTo: activatedRoute,
state: { returnUrl: '/workspace/downloads?q=signal' },
});
expect(navigation.canOpen).not.toHaveBeenCalled();
expect(navigation.open).not.toHaveBeenCalled();
expect(downloadsService.playDownload).not.toHaveBeenCalled();
jest.clearAllMocks();
const playButton = fixture.nativeElement.querySelector(
'.download-library__actions button[aria-label="Play: Completed movie"]'
'.download-library__actions button[aria-label="Play: Signal"]'
) as HTMLButtonElement;
expect(playButton).toBeTruthy();
@@ -749,9 +776,83 @@ describe('DownloadsComponent', () => {
expect(downloadsService.playDownload).toHaveBeenCalledWith(
item.filePath
);
expect(router.navigate).not.toHaveBeenCalled();
expect(navigation.open).not.toHaveBeenCalled();
});
it('opens a grouped-series representative relative to the Xtream downloads route', async () => {
const older = download(18, {
contentType: 'episode',
createdAt: '2026-07-18T12:00:00Z',
episodeNumber: 1,
seasonNumber: 1,
seriesXtreamId: 77,
title: 'Northwind - S01E01 - Arrival',
});
const representative = download(19, {
contentType: 'episode',
createdAt: '2026-07-19T12:00:00Z',
episodeNumber: 2,
seasonNumber: 1,
seriesXtreamId: 77,
title: 'Northwind - S01E02 - Signal',
});
routeParams.next(convertToParamMap({ id: 'playlist-a' }));
router.url = '/workspace/xtream/playlist-a/downloads?q=northwind';
downloads.set([older, representative]);
await fixture.whenStable();
const seriesArtwork = fixture.nativeElement.querySelector(
'[data-test-id="download-library-series-open"].download-library__artwork-button'
) as HTMLButtonElement;
expect(seriesArtwork).toBeTruthy();
seriesArtwork.click();
await fixture.whenStable();
expect(router.navigate).toHaveBeenCalledWith(['19'], {
relativeTo: activatedRoute,
state: {
returnUrl: '/workspace/xtream/playlist-a/downloads?q=northwind',
},
});
expect(navigation.open).not.toHaveBeenCalled();
});
it('keeps offline detail navigation relative to the Stalker downloads route', () => {
routeParams.next(convertToParamMap({ id: 'playlist-a' }));
router.url = '/workspace/stalker/playlist-a/downloads?q=signal';
component.openOfflineDetail(download(22));
expect(router.navigate).toHaveBeenCalledWith(['22'], {
relativeTo: activatedRoute,
state: {
returnUrl: '/workspace/stalker/playlist-a/downloads?q=signal',
},
});
});
it('blocks only the exact pending download from opening offline detail', async () => {
const operation = deferred<{ success: boolean }>();
const pending = download(20, { status: 'downloading' });
const ready = download(21);
downloadsService.pauseDownload.mockReturnValueOnce(operation.promise);
const action = component.runAction({ type: 'pause', item: pending });
component.openOfflineDetail(pending);
component.openOfflineDetail(ready);
expect(router.navigate).toHaveBeenCalledTimes(1);
expect(router.navigate).toHaveBeenCalledWith(['21'], {
relativeTo: activatedRoute,
state: { returnUrl: '/workspace/downloads?q=signal' },
});
operation.resolve({ success: true });
await action;
});
it('does not navigate an item whose source playlist is missing', async () => {
const item = download(13, { playlistId: 'removed-playlist' });
navigation.canOpen.mockReturnValueOnce(false);
@@ -226,6 +226,16 @@ export class DownloadsComponent {
this.destroyRef.onDestroy(cleanup);
}
openOfflineDetail(item: DownloadItem): void {
if (this.pendingIds().has(item.id)) {
return;
}
void this.router.navigate([String(item.id)], {
relativeTo: this.route,
state: { returnUrl: this.router.url },
});
}
async openInLibrary(item: DownloadItem): Promise<void> {
if (this.pendingIds().has(item.id)) {
return;