diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html new file mode 100644 index 000000000..276fd697f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html @@ -0,0 +1,423 @@ +@if (detail(); as offlineDetail) { + + + + + {{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }} + + @if (metadata()?.year; as year) { + @if (positiveFinite(year)) { + {{ year }} + } + } + @if (durationLabel(); as duration) { + {{ duration }} + } + @for (genre of genres(); track genre) { + {{ genre }} + } + @if (metadata()?.rating; as rating) { + @if (positiveFinite(rating)) { + + {{ rating }} + + } + } + @if (metadata()?.status; as status) { + @if (status.trim()) { + + {{ status }} + + } + } + + + + @if (cast().length) { +
+ {{ 'XTREAM.ACTORS' | translate }} +
+ @for ( + person of cast(); + track person.tmdbPersonId ?? person.name + ) { + + @if (person.profileUrl) { + + } @else { + + } + {{ + person.name + }} + + } +
+
+ } + @if (creators().length) { +
+ {{ + 'XTREAM.DIRECTOR' | translate + }} +
+ @for ( + person of creators(); + track person.tmdbPersonId ?? person.name + ) { + + @if (person.profileUrl) { + + } @else { + + } + {{ + person.name + }} + + } +
+
+ } + @if (movieFileSize(); as size) { +
+ + {{ + 'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE' + | translate: { size } + }} + +
+ } +
+ + + @if (offlineDetail.kind === 'movie') { + + } + + + + + @if (!canOpenInPortal()) { + + {{ + 'DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE' + | translate + }} + + } + + @if (offlineDetail.kind === 'movie') { + + + + + } + + + @if (offlineDetail.kind === 'series') { +
+
+ @for ( + season of offlineDetail.seasons; + track seasonTestId(season) + ) { + + } +
+ + @if (selectedSeason(); as season) { +
+ @for ( + episode of season.episodes; + track episode.item.id + ) { +
+
+ + {{ + episodeCoordinate( + episode.seasonNumber, + episode.episodeNumber + ) + }} + + + {{ + episodeTitle( + episode.item, + episode.episodeMetadata?.title + ) + }} + +
+ @if ( + formatItemBytes(episode.item); + as size + ) { + + {{ + 'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE' + | translate: { size } + }} + + } + + + {{ + 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' + | translate + }} + +
+
+
+ + + + + +
+
+ } +
+ } +
+ } +
+} @else { + + @if (showNotFound()) { +
+

+ {{ 'DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_BODY' | translate }} +

+ +
+ } +
+} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss new file mode 100644 index 000000000..e8660562a --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss @@ -0,0 +1,187 @@ +:host { + display: block; + height: 100%; + min-height: 0; +} + +button, +.offline-detail__portal-action { + -webkit-app-region: no-drag; +} + +.offline-detail__available, +.offline-detail__episode-available { + display: inline-flex; + align-items: center; + gap: 6px; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } +} + +.offline-detail__people-group { + min-width: 0; + max-width: 100%; +} + +.offline-detail__person { + cursor: default; + user-select: text; +} + +.offline-detail__portal-action { + display: inline-flex; +} + +.offline-detail__visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.offline-detail__episodes { + display: block; + padding: 24px 0 40px; +} + +.offline-detail__season-tabs { + display: flex; + gap: 8px; + padding-block: 0 16px; + overflow-x: auto; + scrollbar-width: thin; +} + +.offline-detail__season-tab { + display: grid; + flex: 0 0 auto; + gap: 2px; + min-width: 132px; + padding: 10px 14px; + border: 1px solid var(--mat-sys-outline-variant, var(--tag-border)); + border-radius: var(--mat-sys-corner-medium, 10px); + background: var(--mat-sys-surface-container, var(--tag-bg)); + color: var(--mat-sys-on-surface, var(--text-primary)); + font: inherit; + text-align: start; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease; + + &:hover { + background: var(--mat-sys-surface-container-high, var(--tag-bg)); + } + + &--selected { + border-color: var( + --app-selection-border, + var(--mat-sys-primary, var(--text-primary)) + ); + background: var(--mat-sys-secondary-container, var(--tag-bg)); + color: var(--mat-sys-on-secondary-container, var(--text-primary)); + } + + &:focus-visible { + outline: 2px solid + var(--app-selection-border, var(--mat-sys-primary, currentColor)); + outline-offset: 2px; + } +} + +.offline-detail__season-count { + color: var(--mat-sys-on-surface-variant, var(--text-secondary)); + font-size: 0.75rem; +} + +.offline-detail__episode-list { + display: grid; + gap: 8px; +} + +.offline-detail__episode { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 72px; + padding: 12px 12px 12px 16px; + border: 1px solid var(--mat-sys-outline-variant, var(--tag-border)); + border-radius: var(--mat-sys-corner-medium, 10px); + background: var(--mat-sys-surface-container, var(--surface-bg)); +} + +.offline-detail__episode-copy { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + gap: 4px 12px; + min-width: 0; +} + +.offline-detail__coordinate { + color: var(--mat-sys-primary, var(--text-primary)); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +.offline-detail__episode-title { + overflow: hidden; + color: var(--mat-sys-on-surface, var(--text-primary)); + font-size: 0.9375rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.offline-detail__episode-facts { + grid-column: 2; + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + color: var(--mat-sys-on-surface-variant, var(--text-secondary)); + font-size: 0.75rem; +} + +.offline-detail__episode-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.offline-detail__not-found-actions { + display: grid; + justify-items: center; + gap: 12px; + color: var(--mat-sys-on-surface-variant, var(--text-secondary)); + + p { + margin: 0; + } +} + +@media (max-width: 700px) { + .offline-detail__episode { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + .offline-detail__episode-actions { + justify-content: flex-end; + } +} + +@media (prefers-reduced-motion: reduce) { + .offline-detail__season-tab { + transition: none; + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts new file mode 100644 index 000000000..c2b2452e8 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts @@ -0,0 +1,614 @@ +import { Location } from '@angular/common'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + type DownloadItem, + DownloadsService, + PlaylistsService, +} from '@iptvnator/services'; +import type { + DownloadMetadataSnapshot, + Playlist, +} from '@iptvnator/shared/interfaces'; +import { BehaviorSubject, type Observable, throwError } from 'rxjs'; +import { DownloadLibraryNavigationService } from '../download-library-navigation.service'; +import { DownloadManagerActionsService } from '../download-manager-actions.service'; +import { DownloadOfflineDetailComponent } from './download-offline-detail.component'; +import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service'; +import { DownloadOfflineMetadataService } from './download-offline-metadata.service'; +import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service'; + +const TRANSLATIONS = { + DOWNLOADS: { + ACTION_FAILED: 'Action failed', + MORE_ACTIONS: 'More actions', + REVEAL: 'Show in folder', + SEASON: 'Season {{season}}', + OFFLINE_DETAIL: { + AVAILABLE: 'Available offline', + PLAY: 'Play offline', + VIEW_IN_PORTAL: 'View in portal', + VIEW_IN_PORTAL_UNAVAILABLE: 'The source is unavailable', + DOWNLOADED_EPISODES: '{{count}} downloaded episodes', + FILE_SIZE: '{{size}}', + NOT_FOUND_TITLE: 'Download not found', + NOT_FOUND_BODY: 'This download is no longer in the manager.', + BACK: 'Back to Downloads', + }, + }, + XTREAM: { + ACTORS: 'Cast', + DIRECTOR: 'Creators', + }, + SHOW_LESS: 'Show less', + SHOW_MORE: 'Show more', +} as const; + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} + +interface DownloadsFake { + readonly downloads: ReturnType>; + readonly isLoadingDownloads: ReturnType>; + readonly hasLoadedDownloads: ReturnType>; + readonly loadDownloads: jest.Mock, []>; + readonly formatBytes: jest.Mock; +} + +interface ActionsFake { + readonly pendingIds: ReturnType>>; + readonly run: jest.Mock, [unknown]>; + readonly showActionError: jest.Mock; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function snapshot( + mediaKind: DownloadMetadataSnapshot['mediaKind'], + title: string, + overrides: Partial = {} +): DownloadMetadataSnapshot { + return { + version: 1, + language: 'en', + mediaKind, + title, + ...overrides, + }; +} + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: 1_000 + id, + contentType: 'vod', + title: `Download ${id}`, + url: `https://media.example.test/${id}`, + filePath: `/downloads/${id}.mp4`, + fileAvailability: 'available', + status: 'completed', + totalBytes: 1_024, + ...overrides, + }; +} + +function playlist(id: string): Playlist { + return { _id: id, title: id } as Playlist; +} + +describe('DownloadOfflineDetailComponent', () => { + let fixture: ComponentFixture; + let routeParams: BehaviorSubject>; + let playlistItems: BehaviorSubject; + let playlistsObservable: Observable; + let downloads: DownloadsFake; + let actions: ActionsFake; + let navigation: { + canOpen: jest.Mock]>; + open: jest.Mock, [DownloadItem]>; + }; + let metadata: { + resolve: jest.Mock, [unknown]>; + }; + let router: { + url: string; + navigate: jest.Mock; + navigateByUrl: jest.Mock; + }; + let historyState: Record; + let location: { back: jest.Mock; getState: jest.Mock }; + + beforeEach(async () => { + routeParams = new BehaviorSubject( + convertToParamMap({ downloadId: '17' }) + ); + playlistItems = new BehaviorSubject([playlist('playlist-a')]); + playlistsObservable = playlistItems.asObservable(); + downloads = { + downloads: signal([]), + isLoadingDownloads: signal(false), + hasLoadedDownloads: signal(true), + loadDownloads: jest.fn().mockResolvedValue(undefined), + formatBytes: jest.fn((bytes: number) => `${bytes / 1_024} KB`), + }; + actions = { + pendingIds: signal>(new Set()), + run: jest.fn().mockResolvedValue(undefined), + showActionError: jest.fn(), + }; + navigation = { + canOpen: jest.fn((item, availableIds) => + availableIds.has(item.playlistId) + ), + open: jest.fn().mockResolvedValue(true), + }; + metadata = { + resolve: jest.fn(async (detail: unknown) => { + const value = detail as { + kind: 'movie' | 'series'; + item?: DownloadItem; + representative?: DownloadItem; + snapshot?: DownloadMetadataSnapshot; + }; + const item = value.item ?? value.representative; + return ( + value.snapshot ?? + snapshot(value.kind, item?.title ?? 'Offline download') + ); + }), + }; + router = { + url: '/workspace/downloads/17', + navigate: jest.fn().mockResolvedValue(true), + navigateByUrl: jest.fn().mockResolvedValue(true), + }; + historyState = {}; + location = { + back: jest.fn(), + getState: jest.fn(() => historyState), + }; + + const activatedRoute = { + paramMap: routeParams.asObservable(), + snapshot: { + paramMap: routeParams.value, + params: { downloadId: '17' }, + }, + }; + + await TestBed.configureTestingModule({ + imports: [ + DownloadOfflineDetailComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: Location, useValue: location }, + { + provide: DownloadsService, + useValue: downloads, + }, + { + provide: PlaylistsService, + useValue: { + getAllPlaylists: () => playlistsObservable, + }, + }, + ], + }) + .overrideComponent(DownloadOfflineDetailComponent, { + set: { + providers: [ + { + provide: DownloadManagerActionsService, + useValue: actions, + }, + { + provide: DownloadLibraryNavigationService, + useValue: navigation, + }, + { + provide: DownloadOfflineMetadataService, + useValue: metadata, + }, + DownloadOfflineMetadataResolutionService, + DownloadOfflineRouteNavigationService, + ], + }, + }) + .compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', TRANSLATIONS); + translate.use('en'); + }); + + afterEach(() => fixture?.destroy()); + + async function render(items: DownloadItem[] = []): Promise { + downloads.downloads.set(items); + fixture = TestBed.createComponent(DownloadOfflineDetailComponent); + await fixture.whenStable(); + } + + function text(): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; + } + + function button(testId: string): HTMLButtonElement { + const selector = `[data-testid="${testId}"]`; + const element = + (fixture.nativeElement as HTMLElement).querySelector(selector) ?? + document.body.querySelector(selector); + expect(element).toBeTruthy(); + return element as HTMLButtonElement; + } + + it('loads downloads on entry and renders the focused loading state', async () => { + downloads.hasLoadedDownloads.set(false); + downloads.isLoadingDownloads.set(true); + + await render(); + + expect(downloads.loadDownloads).toHaveBeenCalledTimes(1); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + 'ngx-skeleton-loader' + ) + ).toBeTruthy(); + expect(text()).not.toContain('Download not found'); + }); + + it('renders persisted movie metadata immediately with row artwork fallback and local-only actions', async () => { + const movie = download(17, { + posterUrl: 'https://images.example.test/fallback.jpg', + totalBytes: 2_048, + metadataSnapshot: snapshot('movie', 'Signal', { + plot: 'A local-first mystery.', + year: 2026, + durationMinutes: 112, + genres: ['Drama', 'Mystery'], + rating: 8.4, + status: 'Released', + cast: [{ name: 'Ada Actor', role: 'Mara' }], + creators: [{ name: 'Cora Creator' }], + }), + }); + + await render([movie]); + + expect(text()).toContain('Signal'); + expect(text()).toContain('A local-first mystery.'); + expect(text()).toContain('Available offline'); + expect(text()).toContain('2026'); + expect(text()).toContain('112 min'); + expect(text()).toContain('Drama'); + expect(text()).toContain('8.4'); + expect(text()).toContain('Released'); + expect(text()).toContain('Ada Actor'); + expect(text()).toContain('Cora Creator'); + expect(text()).toContain('2 KB'); + expect(text()).not.toContain('{{size}}'); + expect(downloads.formatBytes).toHaveBeenCalledWith(2_048); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + 'img[src="https://images.example.test/fallback.jpg"]' + ) + ).toBeTruthy(); + expect(button('offline-play').textContent).toContain('Play offline'); + expect(button('view-in-portal').textContent).toContain( + 'View in portal' + ); + expect(button('movie-overflow')).toBeTruthy(); + expect(text()).not.toContain('Play from source'); + }); + + it('omits sparse facts and does not format non-positive or non-finite file sizes', async () => { + await render([ + download(17, { + title: 'Sparse movie', + totalBytes: Number.NaN, + bytesDownloaded: 0, + metadataSnapshot: snapshot('movie', 'Sparse movie', { + durationMinutes: -1, + genres: [' '], + rating: Number.POSITIVE_INFINITY, + status: ' ', + year: Number.NaN, + }), + }), + ]); + + expect(text()).toContain('Sparse movie'); + expect(text()).not.toContain('File size'); + expect(text()).not.toContain('Infinity'); + expect(downloads.formatBytes).not.toHaveBeenCalled(); + }); + + it('renders only available series seasons and episodes in natural order with explicit local actions', async () => { + const seriesMetadata = snapshot('series', 'Northwind', { + plot: 'Signals travel north.', + }); + const episode = ( + id: number, + seasonNumber: number, + episodeNumber: number, + title: string, + overrides: Partial = {} + ) => + download(id, { + contentType: 'episode', + seriesXtreamId: 77, + seasonNumber, + episodeNumber, + title, + metadataSnapshot: { + ...seriesMetadata, + episode: { seasonNumber, episodeNumber, title }, + }, + ...overrides, + }); + + await render([ + episode(17, 2, 1, 'Second season'), + episode(18, 1, 2, 'The Signal'), + episode(19, 1, 1, 'Arrival'), + episode(20, 1, 3, 'Missing', { + fileAvailability: 'missing', + }), + ]); + + expect(text()).toContain('Northwind'); + expect(button('view-in-portal')).toBeTruthy(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="offline-play"]' + ) + ).toBeNull(); + const seasons = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '[data-testid^="offline-season-"]' + ) + ) as HTMLButtonElement[]; + expect(seasons).toHaveLength(2); + expect(seasons[0].getAttribute('aria-selected')).toBe('true'); + expect(seasons[0].textContent).toContain('2 downloaded episodes'); + const firstSeasonEpisodes = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '[data-testid^="offline-episode-"]' + ) + ) as HTMLElement[]; + expect( + firstSeasonEpisodes.map(({ dataset }) => dataset['testid']) + ).toEqual(['offline-episode-19', 'offline-episode-18']); + expect(firstSeasonEpisodes[0].textContent).toContain('S01E01'); + expect(firstSeasonEpisodes[0].textContent).toContain('Arrival'); + expect(firstSeasonEpisodes[0].textContent).toContain( + 'Available offline' + ); + expect(button('episode-play-19')).toBeTruthy(); + expect(button('episode-overflow-19')).toBeTruthy(); + + seasons[1].click(); + await fixture.whenStable(); + + expect(seasons[1].getAttribute('aria-selected')).toBe('true'); + expect(text()).toContain('S02E01'); + expect(text()).not.toContain('Missing'); + }); + + it('shows a focused not-found state when the row is absent after the initial load', async () => { + await render(); + + expect(text()).toContain('Download not found'); + expect(text()).toContain('This download is no longer in the manager.'); + expect(button('not-found-back').textContent).toContain( + 'Back to Downloads' + ); + expect(router.navigate).not.toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('keeps View in portal visible but disabled and described when its source playlist is missing', async () => { + playlistItems.next([]); + navigation.canOpen.mockReturnValue(false); + + await render([download(17, { title: 'Orphaned movie' })]); + + const viewButton = button('view-in-portal'); + expect(viewButton.disabled).toBe(true); + expect(viewButton.getAttribute('aria-describedby')).toBe( + 'offline-view-in-portal-unavailable' + ); + expect(text()).toContain('The source is unavailable'); + expect(navigation.canOpen).toHaveBeenCalledWith( + expect.objectContaining({ id: 17 }), + new Set() + ); + }); + + it('keeps local playback available when loading source playlists fails', async () => { + playlistsObservable = throwError( + () => new Error('playlist storage unavailable') + ); + + await render([download(17, { title: 'Offline survivor' })]); + + expect(text()).toContain('Offline survivor'); + expect(button('offline-play').disabled).toBe(false); + expect(button('view-in-portal').disabled).toBe(true); + }); + + it('replace-navigates to the validated manager return URL when a completed file is initially missing', async () => { + historyState = { + navigationId: 4, + returnUrl: '/workspace/downloads?q=signal&filter=movie', + }; + + await render([ + download(17, { + fileAvailability: 'missing', + }), + ]); + + expect(router.navigateByUrl).toHaveBeenCalledWith( + '/workspace/downloads?q=signal&filter=movie', + { replaceUrl: true } + ); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it.each(['play', 'reveal'] as const)( + 'returns to the manager when a local %s race reloads the current file as missing', + async (actionType) => { + const operation = deferred(); + actions.run.mockReturnValueOnce(operation.promise); + await render([download(17), download(18, { title: 'Other' })]); + + button( + actionType === 'play' ? 'offline-play' : 'movie-overflow' + ).click(); + if (actionType === 'reveal') { + await fixture.whenStable(); + button('movie-reveal').click(); + } + actions.pendingIds.set(new Set([17])); + await fixture.whenStable(); + + expect(button('offline-play').disabled).toBe(true); + expect(button('view-in-portal').disabled).toBe(false); + downloads.downloads.set([ + download(17, { fileAvailability: 'missing' }), + download(18, { title: 'Other' }), + ]); + operation.resolve(undefined); + await fixture.whenStable(); + + expect(actions.run).toHaveBeenCalledWith( + expect.objectContaining({ + type: actionType, + item: expect.objectContaining({ id: 17 }), + }) + ); + expect(router.navigate).toHaveBeenCalledWith(['..'], { + relativeTo: expect.anything(), + queryParamsHandling: 'preserve', + replaceUrl: true, + }); + } + ); + + it('reacts to route reuse and displays the new row before metadata resolution completes', async () => { + const secondResolution = deferred(); + metadata.resolve + .mockResolvedValueOnce(snapshot('movie', 'Resolved first')) + .mockReturnValueOnce(secondResolution.promise); + await render([ + download(17, { title: 'Local first' }), + download(18, { title: 'Local second' }), + ]); + + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + + expect(text()).toContain('Local second'); + expect(text()).not.toContain('Resolved first'); + expect(metadata.resolve).toHaveBeenCalledTimes(2); + }); + + it('discards an older asynchronous metadata result after a route-id change', async () => { + const firstResolution = deferred(); + const secondResolution = deferred(); + metadata.resolve + .mockReturnValueOnce(firstResolution.promise) + .mockReturnValueOnce(secondResolution.promise); + await render([ + download(17, { title: 'Local first' }), + download(18, { title: 'Local second' }), + ]); + + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + secondResolution.resolve(snapshot('movie', 'Resolved second')); + await fixture.whenStable(); + firstResolution.resolve(snapshot('movie', 'Stale first')); + await fixture.whenStable(); + + expect(text()).toContain('Resolved second'); + expect(text()).not.toContain('Stale first'); + }); + + it('opens the current provider target and reports a navigation failure without blocking local playback', async () => { + navigation.open.mockResolvedValueOnce(false); + await render([download(17)]); + + button('view-in-portal').click(); + await fixture.whenStable(); + + expect(navigation.open).toHaveBeenCalledWith( + expect.objectContaining({ id: 17 }) + ); + expect(actions.showActionError).toHaveBeenCalledTimes(1); + expect(button('offline-play').disabled).toBe(false); + }); + + it('uses browser Back only for a validated manager return URL', async () => { + historyState = { + navigationId: 9, + returnUrl: '/workspace/downloads?q=signal', + }; + await render([download(17)]); + + ( + (fixture.nativeElement as HTMLElement).querySelector( + '.hero__back-button' + ) as HTMLButtonElement + ).click(); + await fixture.whenStable(); + + expect(location.back).toHaveBeenCalledTimes(1); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('rejects an external return URL and falls back to the scoped parent safely', async () => { + historyState = { + navigationId: 9, + returnUrl: 'https://evil.example.test/workspace/downloads', + }; + router.url = '/workspace/stalker/playlist-a/downloads/17'; + await render([download(17)]); + + ( + (fixture.nativeElement as HTMLElement).querySelector( + '.hero__back-button' + ) as HTMLButtonElement + ).click(); + await fixture.whenStable(); + + expect(location.back).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['..'], { + relativeTo: expect.anything(), + queryParamsHandling: 'preserve', + }); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts index 1b96f8f35..ed09b5b3f 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts @@ -1,10 +1,299 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { PortalDetailShellComponent } from '@iptvnator/ui/components'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + type DownloadItem, + DownloadsService, + PlaylistsService, +} from '@iptvnator/services'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + PortalDetailShellComponent, +} from '@iptvnator/ui/components'; +import { catchError, map, of, startWith } from 'rxjs'; +import { DownloadLibraryNavigationService } from '../download-library-navigation.service'; +import { DownloadManagerActionsService } from '../download-manager-actions.service'; +import { + buildDownloadOfflineDetail, + type DownloadOfflineSeason, +} from './download-offline-detail.viewmodel'; +import { + boundedOfflinePeople, + offlineDetailIdentity, + offlineDetailRepresentative, + offlineDurationLabel, + offlineEpisodeCoordinate, + offlineEpisodeCount, + offlineEpisodeTitle, + offlineFileByteCount, + offlineHasLocalFile, + offlinePositiveFinite, + offlineSeasonKey, + offlineSeasonTestId, + parseOfflineDownloadId, + type OfflineDetailItem, + type OfflineSelectedSeason, +} from './download-offline-detail.presentation'; +import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service'; +import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service'; @Component({ selector: 'app-download-offline-detail', - imports: [PortalDetailShellComponent], - template: '', + imports: [ + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatTooltipModule, + PortalDetailShellComponent, + TranslatePipe, + ], + templateUrl: './download-offline-detail.component.html', + styleUrls: [ + '../../../../../../ui/components/src/lib/styles/detail-view.scss', + './download-offline-detail.component.scss', + ], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + DownloadLibraryNavigationService, + DownloadManagerActionsService, + DownloadOfflineMetadataResolutionService, + DownloadOfflineRouteNavigationService, + ], }) -export class DownloadOfflineDetailComponent {} +export class DownloadOfflineDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly downloadsService = inject(DownloadsService); + private readonly playlistsService = inject(PlaylistsService); + private readonly actions = inject(DownloadManagerActionsService); + private readonly navigation = inject(DownloadLibraryNavigationService); + private readonly metadataResolution = inject( + DownloadOfflineMetadataResolutionService + ); + private readonly routeNavigation = inject( + DownloadOfflineRouteNavigationService + ); + private readonly selectedSeasonState = signal< + OfflineSelectedSeason | undefined + >(undefined); + private redirectedUnavailableId?: number; + + readonly pendingIds = this.actions.pendingIds; + readonly downloadId = toSignal( + this.route.paramMap.pipe( + map((params) => parseOfflineDownloadId(params.get('downloadId'))) + ), + { + initialValue: parseOfflineDownloadId( + this.route.snapshot.paramMap.get('downloadId') + ), + } + ); + readonly detail = computed(() => { + const downloadId = this.downloadId(); + return downloadId === undefined + ? undefined + : buildDownloadOfflineDetail({ + downloadId, + downloads: this.downloadsService.downloads(), + }); + }); + readonly currentIdentity = computed(() => + offlineDetailIdentity(this.detail()) + ); + readonly currentItem = computed(() => + offlineDetailRepresentative(this.detail()) + ); + readonly selectedRow = computed(() => + this.downloadsService + .downloads() + .find(({ id }) => id === this.downloadId()) + ); + readonly metadata = computed(() => { + const detail = this.detail(); + if (!detail) return undefined; + const resolved = this.metadataResolution.resolution(); + return resolved.identity === this.currentIdentity() + ? (resolved.snapshot ?? detail.snapshot) + : detail.snapshot; + }); + readonly title = computed( + () => this.metadata()?.title?.trim() || this.currentItem()?.title || '' + ); + readonly description = computed(() => this.metadata()?.plot?.trim()); + readonly posterUrl = computed( + () => + this.metadata()?.posterUrl?.trim() || + this.currentItem()?.posterUrl?.trim() + ); + readonly backdropUrl = computed(() => this.metadata()?.backdropUrl?.trim()); + readonly genres = computed(() => + (this.metadata()?.genres ?? []) + .map((genre) => genre.trim()) + .filter(Boolean) + .slice(0, 5) + ); + readonly cast = computed(() => boundedOfflinePeople(this.metadata()?.cast)); + readonly creators = computed(() => + boundedOfflinePeople(this.metadata()?.creators) + ); + readonly playlists = toSignal( + this.playlistsService.getAllPlaylists().pipe( + startWith(null), + catchError(() => of([])) + ), + { initialValue: null as Playlist[] | null } + ); + readonly availablePlaylistIds = computed( + () => new Set((this.playlists() ?? []).map(({ _id }) => _id)) + ); + readonly canOpenInPortal = computed(() => { + const item = this.currentItem(); + return ( + item !== undefined && + this.playlists() !== null && + this.navigation.canOpen( + item as DownloadItem, + this.availablePlaylistIds() + ) + ); + }); + readonly showLoading = computed( + () => + !this.detail() && + (!this.downloadsService.hasLoadedDownloads() || + this.downloadsService.isLoadingDownloads()) + ); + readonly showNotFound = computed( + () => + !this.detail() && + !this.showLoading() && + (!this.selectedRow() || this.selectedRow()?.status !== 'completed') + ); + readonly seasons = computed(() => { + const detail = this.detail(); + return detail?.kind === 'series' ? detail.seasons : []; + }); + readonly selectedSeason = computed(() => { + const seasons = this.seasons(); + const selection = this.selectedSeasonState(); + const identity = this.currentIdentity(); + if (!selection || selection.identity !== identity) return seasons[0]; + return ( + seasons.find( + (season) => offlineSeasonKey(season) === selection.key + ) ?? seasons[0] + ); + }); + readonly count = computed(() => offlineEpisodeCount(this.selectedSeason())); + readonly movieFileSize = computed(() => { + const detail = this.detail(); + return detail?.kind === 'movie' + ? this.formatItemBytes(detail.item) + : undefined; + }); + readonly durationLabel = computed(() => + offlineDurationLabel(this.metadata()?.durationMinutes) + ); + readonly seasonTestId = offlineSeasonTestId; + readonly episodeCoordinate = offlineEpisodeCoordinate; + readonly episodeTitle = offlineEpisodeTitle; + readonly positiveFinite = offlinePositiveFinite; + + constructor() { + void this.downloadsService.loadDownloads(); + this.metadataResolution.connect(this.detail, this.currentIdentity); + this.redirectUnavailableRowsReactively(); + } + + selectSeason(season: DownloadOfflineSeason): void { + const identity = this.currentIdentity(); + if (identity) { + this.selectedSeasonState.set({ + identity, + key: offlineSeasonKey(season), + }); + } + } + + isSelectedSeason(season: DownloadOfflineSeason): boolean { + return this.selectedSeason() === season; + } + + isPending(item: OfflineDetailItem): boolean { + return this.pendingIds().has(item.id); + } + + formatItemBytes(item: OfflineDetailItem): string | undefined { + const bytes = offlineFileByteCount(item); + return bytes === undefined + ? undefined + : this.downloadsService.formatBytes(bytes); + } + + async runFileAction( + type: 'play' | 'reveal', + item: OfflineDetailItem + ): Promise { + const identity = this.currentIdentity(); + await this.actions.run({ type, item: item as DownloadItem }); + if (identity !== this.currentIdentity()) return; + const live = this.downloadsService + .downloads() + .find(({ id }) => id === item.id); + if (!offlineHasLocalFile(live)) { + await this.routeNavigation.toManager(true); + } + } + + async viewInPortal(): Promise { + const item = this.currentItem(); + if (!item || !this.canOpenInPortal()) return; + if (!(await this.navigation.open(item as DownloadItem))) { + this.actions.showActionError(); + } + } + + goBack(): void { + this.routeNavigation.back(); + } + + private redirectUnavailableRowsReactively(): void { + effect(() => { + const id = this.downloadId(); + const row = this.selectedRow(); + const loaded = this.downloadsService.hasLoadedDownloads(); + const loading = this.downloadsService.isLoadingDownloads(); + if ( + id === undefined || + !loaded || + loading || + !row || + row.status !== 'completed' || + this.detail() || + this.redirectedUnavailableId === id + ) { + return; + } + this.redirectedUnavailableId = id; + void this.routeNavigation.toManager(true); + }); + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts new file mode 100644 index 000000000..23d25f8c6 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts @@ -0,0 +1,139 @@ +import type { DownloadItem } from '@iptvnator/services'; +import type { DownloadMetadataPerson } from '@iptvnator/shared/interfaces'; +import type { + DeepReadonly, + DownloadOfflineDetail, + DownloadOfflineSeason, +} from './download-offline-detail.viewmodel'; + +export type OfflineDetailItem = DeepReadonly; + +export interface OfflineSelectedSeason { + readonly identity: string; + readonly key: string; +} + +export function parseOfflineDownloadId( + value: string | null +): number | undefined { + const normalized = value?.trim() ?? ''; + if (!/^\d+$/.test(normalized)) return undefined; + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +export function offlineDetailRepresentative( + detail: DownloadOfflineDetail | undefined +): OfflineDetailItem | undefined { + if (!detail) return undefined; + return detail.kind === 'movie' ? detail.item : detail.representative; +} + +export function offlineDetailIdentity( + detail: DownloadOfflineDetail | undefined +): string | undefined { + if (!detail) return undefined; + const item = offlineDetailRepresentative(detail); + if (!item) return undefined; + return detail.kind === 'series' + ? `series:${item.playlistId}:${item.seriesXtreamId ?? item.id}` + : `movie:${item.playlistId}:${item.id}`; +} + +export function offlineSeasonKey(season: DownloadOfflineSeason): string { + return season.seasonNumberState === 'known' + ? `season-${season.seasonNumber}` + : 'season-unknown'; +} + +export function offlineSeasonTestId(season: DownloadOfflineSeason): string { + return `offline-${offlineSeasonKey(season)}`; +} + +export function offlineEpisodeCount( + season: DownloadOfflineSeason | undefined +): number { + return season?.episodes.length ?? 0; +} + +export function offlineEpisodeCoordinate( + seasonNumber: number | null, + episodeNumber: number | null +): string { + const coordinate = (prefix: string, value: number | null) => + `${prefix}${value === null ? '??' : String(value).padStart(2, '0')}`; + return `${coordinate('S', seasonNumber)}${coordinate('E', episodeNumber)}`; +} + +export function offlineEpisodeTitle( + item: OfflineDetailItem, + metadataTitle?: string +): string { + return metadataTitle?.trim() || item.title; +} + +export function offlineFileByteCount( + item: OfflineDetailItem +): number | undefined { + return [item.totalBytes, item.bytesDownloaded].find( + (value): value is number => + value !== undefined && Number.isFinite(value) && value > 0 + ); +} + +export function offlineDurationLabel( + duration: number | undefined +): string | undefined { + return duration !== undefined && Number.isFinite(duration) && duration > 0 + ? `${duration} min` + : undefined; +} + +export function offlinePositiveFinite(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +export function boundedOfflinePeople( + values: readonly DownloadMetadataPerson[] | undefined +): readonly DownloadMetadataPerson[] { + return (values ?? []) + .filter(({ name }) => name.trim().length > 0) + .slice(0, 12); +} + +export function offlineHasLocalFile( + item: OfflineDetailItem | undefined +): boolean { + return ( + item?.status === 'completed' && + !!item.filePath?.trim() && + item.fileAvailability !== 'missing' + ); +} + +export function safeOfflineManagerReturnUrl( + state: unknown, + currentUrl: string +): string | undefined { + if (!state || typeof state !== 'object') return undefined; + const record = state as Record; + const returnUrl = record['returnUrl']; + const navigationId = record['navigationId']; + if ( + typeof returnUrl !== 'string' || + typeof navigationId !== 'number' || + navigationId <= 1 || + !returnUrl.startsWith('/') || + returnUrl.startsWith('//') + ) { + return undefined; + } + const currentPath = currentUrl.split(/[?#]/, 1)[0].replace(/\/$/, ''); + const managerPath = currentPath.replace(/\/[^/]+$/, ''); + const returnPath = returnUrl.split(/[?#]/, 1)[0].replace(/\/$/, ''); + const managerPattern = + /^\/workspace\/(?:downloads|(?:xtreams|stalker)\/[^/?#]+\/downloads)$/; + return managerPattern.test(managerPath) && returnPath === managerPath + ? returnUrl + : undefined; +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts new file mode 100644 index 000000000..fb71565a9 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts @@ -0,0 +1,60 @@ +import { + effect, + inject, + Injectable, + Injector, + signal, + type Signal, +} from '@angular/core'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel'; +import { DownloadOfflineMetadataService } from './download-offline-metadata.service'; + +export interface OfflineMetadataResolution { + readonly generation: number; + readonly identity?: string; + readonly snapshot?: DownloadMetadataSnapshot; +} + +@Injectable() +export class DownloadOfflineMetadataResolutionService { + private readonly metadata = inject(DownloadOfflineMetadataService); + private readonly injector = inject(Injector); + private readonly state = signal({ + generation: 0, + }); + private generation = 0; + + readonly resolution = this.state.asReadonly(); + + connect( + detail: Signal, + identity: Signal + ): void { + effect( + () => { + const currentDetail = detail(); + const currentIdentity = identity(); + const generation = ++this.generation; + this.state.set({ generation, identity: currentIdentity }); + if (!currentDetail || !currentIdentity) return; + void this.metadata + .resolve(currentDetail) + .then((snapshot) => { + if ( + generation === this.generation && + currentIdentity === identity() + ) { + this.state.set({ + generation, + identity: currentIdentity, + snapshot, + }); + } + }) + .catch(() => undefined); + }, + { injector: this.injector } + ); + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts new file mode 100644 index 000000000..0fc9c040a --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts @@ -0,0 +1,39 @@ +import { Location } from '@angular/common'; +import { inject, Injectable } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { safeOfflineManagerReturnUrl } from './download-offline-detail.presentation'; + +@Injectable() +export class DownloadOfflineRouteNavigationService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly location = inject(Location); + + back(): void { + if (this.returnUrl()) { + this.location.back(); + return; + } + void this.toManager(false); + } + + async toManager(replaceUrl: boolean): Promise { + const returnUrl = this.returnUrl(); + if (returnUrl) { + await this.router.navigateByUrl(returnUrl, { replaceUrl }); + return; + } + await this.router.navigate(['..'], { + relativeTo: this.route, + queryParamsHandling: 'preserve', + ...(replaceUrl ? { replaceUrl: true } : {}), + }); + } + + private returnUrl(): string | undefined { + return safeOfflineManagerReturnUrl( + this.location.getState(), + this.router.url + ); + } +}