diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.html b/libs/portal/downloads/feature/src/lib/download-library.component.html index 09081d888..59171fd69 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.html +++ b/libs/portal/downloads/feature/src/lib/download-library.component.html @@ -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) diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts index b0541cd8d..67b32e4e3 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts @@ -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 () => { diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.ts b/libs/portal/downloads/feature/src/lib/download-library.component.ts index 72c5387a3..7a175a4cf 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.ts @@ -38,7 +38,6 @@ import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header }) export class DownloadLibraryComponent { readonly entities = input.required(); - readonly availablePlaylistIds = input.required>(); readonly pendingIds = input>(new Set()); readonly itemAction = output(); readonly openRequested = output(); @@ -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); } } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index cc6620199..f19356ade 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -232,10 +232,9 @@ /> } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 8f686f99f..7d44cf1b1 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -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; clearFinished(): void; openDownloadedSeries(group: DownloadSeriesCardViewModel): void; + openOfflineDetail(item: DownloadItem): void; openInLibrary(item: DownloadItem): Promise; runAction(action: { readonly type: string; @@ -90,6 +96,16 @@ describe('DownloadsComponent', () => { let downloads: ReturnType>; let routeParams: BehaviorSubject>; let queryParams: BehaviorSubject>; + let activatedRoute: { + readonly paramMap: Observable; + readonly queryParamMap: Observable; + readonly snapshot: { + readonly params: Record; + readonly paramMap: ReturnType; + readonly queryParamMap: ReturnType; + }; + }; + let router: { url: string; navigate: jest.Mock }; let playlistItems: BehaviorSubject; let collectionContext: PortalCollectionContextService; let dialogConfigs: ConfirmConfig[]; @@ -131,6 +147,19 @@ describe('DownloadsComponent', () => { downloads = signal([]); 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); diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 9276f205f..e823f1bbd 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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 { if (this.pendingIds().has(item.id)) { return;