feat(downloads): render offline movie and series details

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent e936748fe3
commit a741415a6a
7 files changed
+1756 -5

No files matched your search

@@ -0,0 +1,423 @@
@if (detail(); as offlineDetail) {
<app-portal-detail-shell
[title]="title()"
[description]="description()"
[posterUrl]="posterUrl()"
[backdropUrl]="backdropUrl()"
(backClicked)="goBack()"
>
<ng-template appDetailTags>
<span class="details__tag offline-detail__available">
<mat-icon aria-hidden="true">download_done</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }}
</span>
@if (metadata()?.year; as year) {
@if (positiveFinite(year)) {
<span class="details__tag">{{ year }}</span>
}
}
@if (durationLabel(); as duration) {
<span class="details__tag">{{ duration }}</span>
}
@for (genre of genres(); track genre) {
<span class="details__tag">{{ genre }}</span>
}
@if (metadata()?.rating; as rating) {
@if (positiveFinite(rating)) {
<span class="details__tag details__tag--rating">
<span aria-hidden="true">★</span> {{ rating }}
</span>
}
}
@if (metadata()?.status; as status) {
@if (status.trim()) {
<span class="details__tag details__tag--status">
{{ status }}
</span>
}
}
</ng-template>
<ng-template appDetailMeta>
@if (cast().length) {
<div class="details__meta-item offline-detail__people-group">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<div class="details__cast-strip">
@for (
person of cast();
track person.tmdbPersonId ?? person.name
) {
<span
class="details__cast-chip offline-detail__person"
[title]="
person.role
? person.name + ' — ' + person.role
: person.name
"
>
@if (person.profileUrl) {
<img
class="details__cast-avatar"
[src]="person.profileUrl"
[alt]="person.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
aria-hidden="true"
>
{{ person.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
person.name
}}</span>
</span>
}
</div>
</div>
}
@if (creators().length) {
<div class="details__meta-item offline-detail__people-group">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<div class="details__cast-strip">
@for (
person of creators();
track person.tmdbPersonId ?? person.name
) {
<span
class="details__cast-chip offline-detail__person"
[title]="person.name"
>
@if (person.profileUrl) {
<img
class="details__cast-avatar"
[src]="person.profileUrl"
[alt]="person.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
aria-hidden="true"
>
{{ person.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
person.name
}}</span>
</span>
}
</div>
</div>
}
@if (movieFileSize(); as size) {
<div class="details__meta-item">
<span class="value">
{{
'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE'
| translate: { size }
}}
</span>
</div>
}
</ng-template>
<ng-template appDetailActions>
@if (offlineDetail.kind === 'movie') {
<button
mat-flat-button
type="button"
class="offline-detail__primary-action"
data-testid="offline-play"
[disabled]="isPending(offlineDetail.item)"
(click)="runFileAction('play', offlineDetail.item)"
>
<mat-icon aria-hidden="true">play_arrow</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.PLAY' | translate }}
</button>
}
<span
class="offline-detail__portal-action"
[matTooltip]="
canOpenInPortal()
? ''
: ('DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE'
| translate)
"
>
<button
mat-stroked-button
type="button"
data-testid="view-in-portal"
[disabled]="!canOpenInPortal()"
[attr.aria-describedby]="
canOpenInPortal()
? null
: 'offline-view-in-portal-unavailable'
"
(click)="viewInPortal()"
>
<mat-icon aria-hidden="true">open_in_new</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL' | translate }}
</button>
</span>
@if (!canOpenInPortal()) {
<span
id="offline-view-in-portal-unavailable"
class="offline-detail__visually-hidden"
>
{{
'DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE'
| translate
}}
</span>
}
@if (offlineDetail.kind === 'movie') {
<button
mat-icon-button
type="button"
data-testid="movie-overflow"
[matMenuTriggerFor]="movieMenu"
[disabled]="isPending(offlineDetail.item)"
[attr.aria-label]="
('DOWNLOADS.MORE_ACTIONS' | translate) + ': ' + title()
"
>
<mat-icon aria-hidden="true">more_vert</mat-icon>
</button>
<mat-menu #movieMenu="matMenu">
<button
mat-menu-item
type="button"
data-testid="movie-reveal"
[disabled]="isPending(offlineDetail.item)"
(click)="runFileAction('reveal', offlineDetail.item)"
>
<mat-icon aria-hidden="true">folder_open</mat-icon>
<span>{{ 'DOWNLOADS.REVEAL' | translate }}</span>
</button>
</mat-menu>
}
</ng-template>
@if (offlineDetail.kind === 'series') {
<section
detail-episodes
class="offline-detail__episodes"
[attr.aria-label]="
'DOWNLOADS.OFFLINE_DETAIL.DOWNLOADED_EPISODES'
| translate
: {
count: count(),
}
"
>
<div class="offline-detail__season-tabs" role="tablist">
@for (
season of offlineDetail.seasons;
track seasonTestId(season)
) {
<button
type="button"
role="tab"
class="offline-detail__season-tab"
[class.offline-detail__season-tab--selected]="
isSelectedSeason(season)
"
[attr.aria-selected]="isSelectedSeason(season)"
[attr.aria-controls]="
seasonTestId(season) + '-panel'
"
[attr.data-testid]="seasonTestId(season)"
(click)="selectSeason(season)"
>
<span>
@if (season.seasonNumberState === 'known') {
{{
'DOWNLOADS.SEASON'
| translate
: {
season: season.seasonNumber,
}
}}
} @else {
S??
}
</span>
<span class="offline-detail__season-count">
{{
'DOWNLOADS.OFFLINE_DETAIL.DOWNLOADED_EPISODES'
| translate
: { count: season.episodes.length }
}}
</span>
</button>
}
</div>
@if (selectedSeason(); as season) {
<div
role="tabpanel"
class="offline-detail__episode-list"
[id]="seasonTestId(season) + '-panel'"
>
@for (
episode of season.episodes;
track episode.item.id
) {
<article
class="offline-detail__episode"
[attr.data-testid]="
'offline-episode-' + episode.item.id
"
>
<div class="offline-detail__episode-copy">
<span class="offline-detail__coordinate">
{{
episodeCoordinate(
episode.seasonNumber,
episode.episodeNumber
)
}}
</span>
<strong
class="offline-detail__episode-title"
>
{{
episodeTitle(
episode.item,
episode.episodeMetadata?.title
)
}}
</strong>
<div class="offline-detail__episode-facts">
@if (
formatItemBytes(episode.item);
as size
) {
<span>
{{
'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE'
| translate: { size }
}}
</span>
}
<span
class="offline-detail__episode-available"
>
<mat-icon aria-hidden="true"
>download_done</mat-icon
>
{{
'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE'
| translate
}}
</span>
</div>
</div>
<div class="offline-detail__episode-actions">
<button
mat-flat-button
type="button"
[attr.data-testid]="
'episode-play-' + episode.item.id
"
[disabled]="isPending(episode.item)"
(click)="
runFileAction('play', episode.item)
"
>
<mat-icon aria-hidden="true"
>play_arrow</mat-icon
>
{{
'DOWNLOADS.OFFLINE_DETAIL.PLAY'
| translate
}}
</button>
<button
mat-icon-button
type="button"
[matMenuTriggerFor]="episodeMenu"
[disabled]="isPending(episode.item)"
[attr.data-testid]="
'episode-overflow-' +
episode.item.id
"
[attr.aria-label]="
('DOWNLOADS.MORE_ACTIONS'
| translate) +
': ' +
episodeTitle(
episode.item,
episode.episodeMetadata?.title
)
"
>
<mat-icon aria-hidden="true"
>more_vert</mat-icon
>
</button>
<mat-menu #episodeMenu="matMenu">
<button
mat-menu-item
type="button"
[disabled]="isPending(episode.item)"
(click)="
runFileAction(
'reveal',
episode.item
)
"
>
<mat-icon aria-hidden="true">
folder_open
</mat-icon>
<span>{{
'DOWNLOADS.REVEAL' | translate
}}</span>
</button>
</mat-menu>
</div>
</article>
}
</div>
}
</section>
}
</app-portal-detail-shell>
} @else {
<app-portal-detail-shell
[isLoading]="showLoading()"
[errorMessage]="
showNotFound()
? ('DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_TITLE' | translate)
: undefined
"
(backClicked)="goBack()"
>
@if (showNotFound()) {
<div hero-error-actions class="offline-detail__not-found-actions">
<p>
{{ 'DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_BODY' | translate }}
</p>
<button
mat-stroked-button
type="button"
data-testid="not-found-back"
(click)="goBack()"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
{{ 'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate }}
</button>
</div>
}
</app-portal-detail-shell>
}
@@ -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;
}
}
@@ -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<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
interface DownloadsFake {
readonly downloads: ReturnType<typeof signal<DownloadItem[]>>;
readonly isLoadingDownloads: ReturnType<typeof signal<boolean>>;
readonly hasLoadedDownloads: ReturnType<typeof signal<boolean>>;
readonly loadDownloads: jest.Mock<Promise<void>, []>;
readonly formatBytes: jest.Mock<string, [number]>;
}
interface ActionsFake {
readonly pendingIds: ReturnType<typeof signal<ReadonlySet<number>>>;
readonly run: jest.Mock<Promise<void>, [unknown]>;
readonly showActionError: jest.Mock<void, []>;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function snapshot(
mediaKind: DownloadMetadataSnapshot['mediaKind'],
title: string,
overrides: Partial<DownloadMetadataSnapshot> = {}
): DownloadMetadataSnapshot {
return {
version: 1,
language: 'en',
mediaKind,
title,
...overrides,
};
}
function download(
id: number,
overrides: Partial<DownloadItem> = {}
): 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<DownloadOfflineDetailComponent>;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let playlistItems: BehaviorSubject<Playlist[]>;
let playlistsObservable: Observable<Playlist[]>;
let downloads: DownloadsFake;
let actions: ActionsFake;
let navigation: {
canOpen: jest.Mock<boolean, [DownloadItem, ReadonlySet<string>]>;
open: jest.Mock<Promise<boolean>, [DownloadItem]>;
};
let metadata: {
resolve: jest.Mock<Promise<DownloadMetadataSnapshot>, [unknown]>;
};
let router: {
url: string;
navigate: jest.Mock;
navigateByUrl: jest.Mock;
};
let historyState: Record<string, unknown>;
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<DownloadItem[]>([]),
isLoadingDownloads: signal(false),
hasLoadedDownloads: signal(true),
loadDownloads: jest.fn().mockResolvedValue(undefined),
formatBytes: jest.fn((bytes: number) => `${bytes / 1_024} KB`),
};
actions = {
pendingIds: signal<ReadonlySet<number>>(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<void> {
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<DownloadItem> = {}
) =>
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<void>();
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<DownloadMetadataSnapshot>();
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<DownloadMetadataSnapshot>();
const secondResolution = deferred<DownloadMetadataSnapshot>();
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();
});
});
@@ -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: '<app-portal-detail-shell [isLoading]="true" />',
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<void> {
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<void> {
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);
});
}
}
@@ -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<DownloadItem>;
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<string, unknown>;
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;
}
@@ -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<OfflineMetadataResolution>({
generation: 0,
});
private generation = 0;
readonly resolution = this.state.asReadonly();
connect(
detail: Signal<DownloadOfflineDetail | undefined>,
identity: Signal<string | undefined>
): 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 }
);
}
}
@@ -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<void> {
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
);
}
}