feat(downloads): add selected season queue action

This commit is contained in:
4gray committed 2026-08-02 13:46:38 +02:00
1 parent e6cc9e5ff9
commit f986c2f0b6
6 files changed
+1058 -620

No files matched your search

@@ -1,164 +0,0 @@
import {
DownloadMetadataSnapshot,
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import {
createMovieDownloadSnapshot,
createSeriesEpisodeDownloadSnapshot,
type DownloadMovieSnapshotInput,
} from '@iptvnator/portal/shared/util';
/**
* Pure helpers for mapping episodes (Xtream and Stalker-mapped) to the
* download-manager content ids and download requests used by the season
* container.
*/
export interface XtreamEpisodeDownloadContext {
serverUrl?: string;
username?: string;
password?: string;
}
/** Provider-neutral metadata already loaded by the detail host. */
export type SeasonContainerDownloadMetadataContext = DownloadMovieSnapshotInput;
export interface XtreamEpisodeDownloadRequest {
playlistId: string;
xtreamId: number;
contentType: 'episode';
title: string;
url: string;
posterUrl?: string;
seriesXtreamId: number;
seasonNumber: number;
episodeNumber: number;
metadataSnapshot?: DownloadMetadataSnapshot;
}
function rowCoordinate(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
function buildMetadataSnapshot(
metadata: SeasonContainerDownloadMetadataContext,
episode: XtreamSerieEpisode,
seasonNumber: unknown,
episodeNumber: unknown
): DownloadMetadataSnapshot {
const normalizedSeason = rowCoordinate(seasonNumber);
const normalizedEpisode = rowCoordinate(episodeNumber);
if (normalizedSeason === undefined || normalizedEpisode === undefined) {
return {
...createMovieDownloadSnapshot(metadata),
mediaKind: 'series',
};
}
const info = Array.isArray(episode.info)
? undefined
: (episode.info as XtreamSerieEpisodeInfo);
return createSeriesEpisodeDownloadSnapshot({
...metadata,
episode: {
seasonNumber: normalizedSeason,
episodeNumber: normalizedEpisode,
title: episode.title,
plot: info?.plot,
stillUrl: info?.movie_image,
},
});
}
export function hashString(str: string): number {
let hash = 0;
for (let index = 0; index < str.length; index++) {
const char = str.charCodeAt(index);
hash = (hash << 5) - hash + char;
hash &= hash;
}
return Math.abs(hash);
}
export function isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
const customSid = (episode as { custom_sid?: string }).custom_sid;
return customSid === 'vod-series' || customSid === 'regular-series';
}
export function getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as { custom_sid?: string }).custom_sid;
if (customSid === 'regular-series') {
const cmd = (episode as { originalCmd?: string }).originalCmd;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
return hashString(cmd);
}
return Number(episode.id);
}
if (customSid === 'vod-series') {
const originalId = (episode as { originalId?: string | number })
.originalId;
const numericId = Number(originalId);
return Number.isNaN(numericId)
? hashString(String(originalId))
: numericId;
}
const numericId = Number(episode.id);
return Number.isNaN(numericId) ? hashString(String(episode.id)) : numericId;
}
export function buildXtreamEpisodeDownloadRequest(options: {
episode: XtreamSerieEpisode;
context: XtreamEpisodeDownloadContext;
playlistId: string;
seriesId: number;
seriesTitle: string;
fallbackSeasonKey: string | undefined;
posterUrl?: string;
metadataContext?: SeasonContainerDownloadMetadataContext;
}): XtreamEpisodeDownloadRequest {
const { episode, context, playlistId, seriesId, seriesTitle } = options;
const serverUrl = context.serverUrl?.replace(/\/$/, '') || '';
const username = context.username || '';
const password = context.password || '';
const extension = episode.container_extension || 'mp4';
const seasonNumber =
episode.season || Number(options.fallbackSeasonKey) || 1;
const episodeNumber = episode.episode_num || 1;
const title = `${seriesTitle || 'Series'} - S${String(
seasonNumber
).padStart(
2,
'0'
)}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`;
return {
playlistId,
xtreamId: Number(episode.id),
contentType: 'episode',
title,
url: `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`,
posterUrl: options.posterUrl,
seriesXtreamId: seriesId,
seasonNumber,
episodeNumber,
...(options.metadataContext
? {
metadataSnapshot: buildMetadataSnapshot(
options.metadataContext,
episode,
seasonNumber,
episodeNumber
),
}
: {}),
};
}
@@ -1,158 +1,8 @@
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import {
buildXtreamEpisodeDownloadRequest,
getEpisodeDownloadId,
hashString,
isStalkerEpisode,
} from './episode-download.util';
import {
formatEpisodePositionText,
parseDuration,
} from './episode-progress.util';
const episode = (overrides: Record<string, unknown>): XtreamSerieEpisode =>
({ id: '10', episode_num: 1, title: 'Ep', ...overrides }) as never;
describe('episode-download.util', () => {
it('detects stalker-mapped episodes', () => {
expect(isStalkerEpisode(episode({ custom_sid: 'vod-series' }))).toBe(
true
);
expect(
isStalkerEpisode(episode({ custom_sid: 'regular-series' }))
).toBe(true);
expect(isStalkerEpisode(episode({}))).toBe(false);
});
it('resolves download ids per episode source', () => {
expect(getEpisodeDownloadId(episode({}))).toBe(10);
expect(
getEpisodeDownloadId(
episode({
custom_sid: 'regular-series',
originalCmd: '/media/file_777.mpg',
})
)
).toBe(777);
expect(
getEpisodeDownloadId(
episode({ custom_sid: 'vod-series', originalId: '42' })
)
).toBe(42);
expect(
getEpisodeDownloadId(
episode({ custom_sid: 'vod-series', originalId: 'abc' })
)
).toBe(hashString('abc'));
});
it('builds an Xtream episode download request', () => {
const request = buildXtreamEpisodeDownloadRequest({
episode: episode({
id: '55',
episode_num: 3,
season: 2,
container_extension: 'mkv',
title: 'The One',
}),
context: {
serverUrl: 'http://host/',
username: 'u',
password: 'p',
},
playlistId: 'pl-1',
seriesId: 900,
seriesTitle: 'Show',
fallbackSeasonKey: '1',
});
expect(request).toEqual({
playlistId: 'pl-1',
xtreamId: 55,
contentType: 'episode',
title: 'Show - S02E03 - The One',
url: 'http://host/series/u/p/55.mkv',
posterUrl: undefined,
seriesXtreamId: 900,
seasonNumber: 2,
episodeNumber: 3,
});
});
it('adds the loaded series and episode metadata when provided', () => {
const request = buildXtreamEpisodeDownloadRequest({
episode: episode({
id: '55',
episode_num: 3,
season: 2,
title: 'The One',
info: {
plot: 'Episode plot',
movie_image: 'https://images.test/stills/one.jpg',
},
}),
context: { serverUrl: 'http://host' },
playlistId: 'pl-1',
seriesId: 900,
seriesTitle: 'Show',
fallbackSeasonKey: '1',
metadataContext: {
language: 'de',
title: 'Show',
plot: 'Series plot',
genres: ['Drama'],
posterUrl: 'poster.jpg',
providerCategoryId: '7',
cast: [{ name: 'Actor' }],
},
});
expect(request.metadataSnapshot).toEqual(
expect.objectContaining({
language: 'de',
mediaKind: 'series',
title: 'Show',
plot: 'Series plot',
genres: ['Drama'],
providerCategoryId: '7',
cast: [{ name: 'Actor' }],
episode: {
seasonNumber: 2,
episodeNumber: 3,
title: 'The One',
plot: 'Episode plot',
stillUrl: 'https://images.test/stills/one.jpg',
},
})
);
});
it('creates a valid sparse snapshot without provider enrichment', () => {
const request = buildXtreamEpisodeDownloadRequest({
episode: episode({ season: 1, info: [] }),
context: {},
playlistId: 'pl-1',
seriesId: 900,
seriesTitle: 'Show',
fallbackSeasonKey: undefined,
metadataContext: { language: 'en', title: 'Show' },
});
expect(request.metadataSnapshot).toEqual(
expect.objectContaining({
language: 'en',
mediaKind: 'series',
title: 'Show',
episode: {
seasonNumber: 1,
episodeNumber: 1,
title: 'Ep',
},
})
);
});
});
describe('episode-progress.util', () => {
it('parses duration strings', () => {
expect(parseDuration('01:00:30')).toBe(3630);
@@ -5,26 +5,61 @@
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
@if (
downloadPresentationVisible() ||
(hasSeasons() && !showSeasonEmptyState())
) {
<div class="section-header__actions">
@if (downloadPresentationVisible()) {
<button
mat-stroked-button
class="season-download-button"
data-test-id="download-season"
[disabled]="seasonDownloadDisabled()"
[attr.aria-label]="
'DOWNLOADS.DOWNLOAD_SEASON_ARIA'
| translate: { count: eligibleEpisodeCount() }
"
(click)="downloadSelectedSeason()"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
@if (batchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
{{ 'DOWNLOADS.ADDING_TO_QUEUE' | translate }}
} @else {
<mat-icon>download</mat-icon>
{{
'DOWNLOADS.DOWNLOAD_SEASON'
| translate
: { count: eligibleEpisodeCount() }
}}
}
</button>
}
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</div>
}
</div>
@@ -155,73 +190,52 @@
}}</mat-icon>
</button>
<div class="episode-card__actions">
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="
playFromLocal(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="download-btn download-btn--paused"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME'
| translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (
isEpisodeDownloading(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="
downloadEpisode(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD'
| translate
"
>
<mat-icon>download</mat-icon>
</button>
}
@if (downloadPresentationVisible()) {
@let downloadState =
episodeDownloadState(episode);
<button
mat-icon-button
class="download-btn"
[class.download-btn--completed]="
downloadState.action === 'play'
"
[class.download-btn--paused]="
downloadState.action ===
'resume'
"
[class.download-btn--downloading]="
downloadState.disabled
"
[disabled]="downloadState.disabled"
[attr.data-test-id]="
'episode-download-' + episode.id
"
[attr.aria-label]="
downloadState.ariaKey
| translate
: {
title: episode.title,
}
"
[matTooltip]="
downloadState.ariaKey
| translate
: {
title: episode.title,
}
"
(click)="
runEpisodeDownloadAction(
$event,
episode,
downloadState
)
"
>
<mat-icon>{{
downloadState.icon
}}</mat-icon>
</button>
}
</div>
<div class="episode-card__overlay">
@@ -412,63 +426,45 @@
}}</mat-icon>
}
</button>
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="
playFromLocal($event, episode)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="action-btn"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="action-btn"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="action-btn"
(click)="
downloadEpisode($event, episode)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD' | translate
"
>
<mat-icon>download</mat-icon>
</button>
}
@if (downloadPresentationVisible()) {
@let downloadState =
episodeDownloadState(episode);
<button
mat-icon-button
class="action-btn"
[class.action-btn--success]="
downloadState.action === 'play'
"
[disabled]="downloadState.disabled"
[attr.data-test-id]="
'episode-download-' + episode.id
"
[attr.aria-label]="
downloadState.ariaKey
| translate
: {
title: episode.title,
}
"
[matTooltip]="
downloadState.ariaKey
| translate
: {
title: episode.title,
}
"
(click)="
runEpisodeDownloadAction(
$event,
episode,
downloadState
)
"
>
<mat-icon>{{
downloadState.icon
}}</mat-icon>
</button>
}
@if (episodeInfo?.plot) {
<button
@@ -18,6 +18,14 @@
flex: 1;
}
&__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
@@ -77,6 +85,15 @@
}
}
.season-download-button {
min-height: 36px;
mat-spinner {
display: inline-block;
margin-inline-end: 8px;
}
}
.loading-container {
display: flex;
justify-content: center;
@@ -95,8 +112,16 @@
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.03)
),
radial-gradient(
circle at top,
rgba(255, 255, 255, 0.06),
transparent 60%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 40px rgba(0, 0, 0, 0.22);
@@ -138,8 +163,16 @@
&--season {
border-color: rgba(255, 255, 255, 0.1);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)),
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%);
linear-gradient(
180deg,
rgba(255, 255, 255, 0.07),
rgba(255, 255, 255, 0.04)
),
radial-gradient(
circle at top,
rgba(255, 255, 255, 0.08),
transparent 65%
);
}
}
@@ -168,7 +201,11 @@ app-season-tabs {
// In-progress state
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 60%,
transparent
);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&::before {
@@ -187,8 +224,13 @@ app-season-tabs {
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-tertiary) 56%,
transparent
);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
}
&--launching {
@@ -199,13 +241,22 @@ app-season-tabs {
}
&--active-external {
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-tertiary-container) 18%,
transparent
);
}
// Playing in the inline player
&--playing {
border-color: color-mix(in srgb, var(--mat-sys-primary) 70%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 70%,
transparent
);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
}
// Watched state — the sole indicator is the watched toggle on the
@@ -386,7 +437,11 @@ app-season-tabs {
color: var(--mat-sys-tertiary) !important;
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-tertiary) 80%,
transparent
);
}
}
@@ -567,12 +622,24 @@ app-season-tabs {
}
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 40%,
transparent
);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&:hover {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 60%,
transparent
);
background: color-mix(
in srgb,
var(--mat-sys-primary) 8%,
transparent
);
}
}
@@ -753,7 +820,11 @@ app-season-tabs {
color: var(--mat-sys-tertiary);
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-tertiary) 15%,
transparent
);
}
}
@@ -767,12 +838,24 @@ app-season-tabs {
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 46%, transparent);
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-tertiary) 46%,
transparent
);
background: color-mix(
in srgb,
var(--mat-sys-tertiary-container) 12%,
transparent
);
}
&--playing {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 60%,
transparent
);
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
}
@@ -783,3 +866,15 @@ app-season-tabs {
}
}
}
@media (max-width: 560px) {
.section-header {
align-items: flex-start;
flex-wrap: wrap;
&__actions {
width: 100%;
justify-content: space-between;
}
}
}
@@ -1,22 +1,66 @@
import { signal } from '@angular/core';
import { signal, type WritableSignal } from '@angular/core';
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 { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
SeasonDownloadCoordinator,
type EpisodeDownloadCandidate,
type SeasonEpisodeDownloadAdapter,
} from '@iptvnator/portal/shared/data-access';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { DownloadsService } from '@iptvnator/services';
import {
DownloadsService,
type DownloadItem,
type DownloadStartInput,
} from '@iptvnator/services';
import { of } from 'rxjs';
import { EPISODE_INFO_PLAY } from './episode-info-dialog.component';
import { SeasonContainerComponent } from './season-container.component';
const downloadsServiceStub = {
isAvailable: signal(false),
downloads: () => [],
startDownload: jest.fn().mockResolvedValue(undefined),
isDownloaded: () => false,
isDownloading: () => false,
getDownloadedFilePath: () => '',
playDownload: async () => undefined,
interface DownloadsServiceStub {
readonly isAvailable: WritableSignal<boolean>;
readonly hasLoadedDownloads: WritableSignal<boolean>;
readonly downloads: WritableSignal<DownloadItem[]>;
readonly startDownload: jest.MockedFunction<
DownloadsService['startDownload']
>;
readonly loadDownloads: jest.MockedFunction<
DownloadsService['loadDownloads']
>;
readonly resumeDownload: jest.MockedFunction<
DownloadsService['resumeDownload']
>;
readonly playDownload: jest.MockedFunction<
DownloadsService['playDownload']
>;
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
const DOWNLOAD_TRANSLATIONS = {
DOWNLOADS: {
DOWNLOAD: 'Download',
PLAY_LOCAL: 'Play local',
RESUME: 'Resume',
NOT_AVAILABLE: 'Downloads not available',
DOWNLOAD_SEASON: 'Download season ({{count}})',
DOWNLOAD_SEASON_ARIA: 'Download {{count}} eligible episodes',
ADDING_TO_QUEUE: 'Adding to queue',
EPISODE_DOWNLOAD_ARIA: 'Download {{title}}',
EPISODE_DOWNLOAD_PENDING_ARIA: 'Adding {{title}} to queue',
EPISODE_DOWNLOAD_FAILED: 'The episode could not be added',
SEASON_QUEUE_RESULT:
'Added {{added}}, skipped {{skipped}}, failed {{failed}}',
SEASON_QUEUE_RESULT_WITH_FAILURES:
'Queue failed: {{added}} added, {{skipped}} skipped, {{failed}} failed',
STATUS: { DOWNLOADING: 'Downloading' },
ARIA: { RESUME: 'Resume {{title}}' },
},
};
function createEpisode(
@@ -40,26 +84,123 @@ function createEpisode(
};
}
function createDownloadAdapter(): SeasonEpisodeDownloadAdapter {
return {
createCandidate(
episode: XtreamSerieEpisode,
fallbackSeasonKey: string | undefined
): EpisodeDownloadCandidate | null {
const xtreamId = Number(episode.id);
const seasonNumber = Number(
episode.season || fallbackSeasonKey || 1
);
const episodeNumber = Number(episode.episode_num);
if (
!Number.isSafeInteger(xtreamId) ||
xtreamId <= 0 ||
!Number.isSafeInteger(seasonNumber) ||
seasonNumber <= 0 ||
!Number.isSafeInteger(episodeNumber) ||
episodeNumber <= 0
) {
return null;
}
const identity = {
playlistId: 'playlist-1',
contentType: 'episode' as const,
xtreamId,
seriesXtreamId: 20,
seasonNumber,
episodeNumber,
};
return {
identity,
prepare: async (): Promise<DownloadStartInput> => ({
...identity,
title: episode.title,
url: `https://stream.test/${episode.id}`,
}),
};
},
};
}
function createDownload(
episode: XtreamSerieEpisode,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id: Number(episode.id),
playlistId: 'playlist-1',
xtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: 20,
seasonNumber: Number(episode.season || 1),
episodeNumber: Number(episode.episode_num),
title: episode.title,
url: `https://stream.test/${episode.id}`,
status: 'queued',
...overrides,
};
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
const dialogOpen = jest.fn();
describe('SeasonContainerComponent', () => {
let fixture: ComponentFixture<SeasonContainerComponent>;
let component: SeasonContainerComponent;
let downloadsServiceStub: DownloadsServiceStub;
let downloadAdapter: SeasonEpisodeDownloadAdapter;
let emittedSeasons: string[];
const snackBarOpen = jest.fn();
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>,
isLoading = false
) => {
fixture.componentRef.setInput('seasons', seasons);
fixture.componentRef.setInput('seriesId', 1);
fixture.componentRef.setInput('seriesId', 20);
fixture.componentRef.setInput('playlistId', 'playlist-1');
fixture.componentRef.setInput('isLoading', isLoading);
};
const enableDownloads = (
adapter: SeasonEpisodeDownloadAdapter | null = downloadAdapter
) => {
downloadsServiceStub.isAvailable.set(true);
downloadsServiceStub.hasLoadedDownloads.set(true);
fixture.componentRef.setInput('downloadAdapter', adapter);
};
const episodeAction = (episodeId: string | number): HTMLButtonElement => {
const button = fixture.nativeElement.querySelector(
`[data-test-id="episode-download-${episodeId}"]`
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
return button as HTMLButtonElement;
};
beforeEach(async () => {
downloadsServiceStub.isAvailable.set(false);
downloadsServiceStub.startDownload.mockClear();
downloadsServiceStub = {
isAvailable: signal(false),
hasLoadedDownloads: signal(false),
downloads: signal<DownloadItem[]>([]),
startDownload: jest.fn().mockResolvedValue({ success: true }),
loadDownloads: jest.fn().mockResolvedValue(undefined),
resumeDownload: jest.fn().mockResolvedValue({ success: true }),
playDownload: jest.fn().mockResolvedValue({ success: true }),
};
downloadAdapter = createDownloadAdapter();
snackBarOpen.mockReset();
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
@@ -75,9 +216,19 @@ describe('SeasonContainerComponent', () => {
provide: MatDialog,
useValue: { open: dialogOpen },
},
SeasonDownloadCoordinator,
{
provide: MatSnackBar,
useValue: { open: snackBarOpen },
},
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', DOWNLOAD_TRANSLATIONS);
translate.setDefaultLang('en');
translate.use('en');
fixture = TestBed.createComponent(SeasonContainerComponent);
component = fixture.componentInstance;
emittedSeasons = [];
@@ -149,41 +300,6 @@ describe('SeasonContainerComponent', () => {
).toBe(0);
});
it('forwards the optional series metadata into the Xtream request', async () => {
const target = createEpisode();
setRequiredInputs({ '1': [target] });
fixture.componentRef.setInput('seriesTitle', 'Signal House');
fixture.componentRef.setInput('xtreamDownloadContext', {
serverUrl: 'http://host',
username: 'u',
password: 'p',
});
fixture.componentRef.setInput('downloadMetadataContext', {
language: 'en',
title: 'Signal House',
plot: 'Series plot',
});
fixture.detectChanges();
await component.downloadEpisode(new Event('click'), target);
expect(downloadsServiceStub.startDownload).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Signal House - S01E01 - Pilot',
metadataSnapshot: expect.objectContaining({
mediaKind: 'series',
title: 'Signal House',
plot: 'Series plot',
episode: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
title: 'Pilot',
}),
}),
})
);
});
it('renders the season-level placeholder when the selected season has no episodes', () => {
setRequiredInputs({ '1': [] });
fixture.detectChanges();
@@ -422,4 +538,418 @@ describe('SeasonContainerComponent', () => {
)?.textContent
).toContain('Season one overview');
});
it('marks only the submitted episode pending synchronously', async () => {
const first = createEpisode();
const second = createEpisode({
id: '102',
episode_num: 2,
title: 'Second',
});
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': [first, second] });
fixture.detectChanges();
episodeAction(101).click();
fixture.detectChanges();
expect(episodeAction(101).disabled).toBe(true);
expect(episodeAction(101).getAttribute('aria-label')).toBe(
'Adding Pilot to queue'
);
expect(episodeAction(102).disabled).toBe(false);
expect(episodeAction(102).getAttribute('aria-label')).toBe(
'Download Second'
);
start.resolve({ success: true });
await Promise.resolve();
await Promise.resolve();
await fixture.whenStable();
fixture.detectChanges();
expect(episodeAction(101).disabled).toBe(false);
});
it('shows the eligible season count before the view toggle and reports skipped rows truthfully', async () => {
const paused = createEpisode();
const eligible = createEpisode({
id: '102',
episode_num: 2,
title: 'Second',
});
downloadsServiceStub.downloads.set([
createDownload(paused, { id: 41, status: 'paused' }),
]);
enableDownloads();
setRequiredInputs({ '1': [paused, eligible] });
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(button.textContent).toContain('Download season (1)');
expect(button.getAttribute('aria-label')).toBe(
'Download 1 eligible episodes'
);
expect(button.nextElementSibling?.classList).toContain('view-toggle');
button.click();
await fixture.whenStable();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
expect(snackBarOpen).toHaveBeenCalledWith(
'Added 1, skipped 1, failed 0',
undefined,
{ duration: 5000 }
);
});
it('disables the season action until authoritative eligible work exists and while work is in flight', async () => {
const first = createEpisode();
enableDownloads();
downloadsServiceStub.hasLoadedDownloads.set(false);
setRequiredInputs({ '1': [first] });
fixture.detectChanges();
const seasonButton = () =>
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(seasonButton().disabled).toBe(true);
downloadsServiceStub.hasLoadedDownloads.set(true);
fixture.componentRef.setInput('isLoading', true);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
fixture.componentRef.setInput('isLoading', false);
fixture.componentRef.setInput('seasons', { '1': [] });
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
fixture.componentRef.setInput('seasons', { '1': [first] });
downloadsServiceStub.downloads.set([
createDownload(first, { status: 'paused' }),
]);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
downloadsServiceStub.downloads.set([]);
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
fixture.detectChanges();
seasonButton().click();
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
start.resolve({ success: true });
await fixture.whenStable();
});
it('hides all download presentation on web, in provider-only mode, or without an adapter', () => {
const first = createEpisode();
setRequiredInputs({ '1': [first] });
fixture.componentRef.setInput('downloadAdapter', downloadAdapter);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
downloadsServiceStub.isAvailable.set(true);
downloadsServiceStub.hasLoadedDownloads.set(true);
fixture.componentRef.setInput('downloadsEnabled', false);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.download-btn')).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
fixture.componentRef.setInput('downloadsEnabled', true);
fixture.componentRef.setInput('downloadAdapter', null);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(fixture.nativeElement.querySelector('.download-btn')).toBeNull();
});
it.each(['grid', 'list'] as const)(
'maps every managed state to the same %s action contract',
async (viewMode) => {
const episodes = [
createEpisode(),
createEpisode({ id: '102', episode_num: 2, title: 'Queued' }),
createEpisode({ id: '103', episode_num: 3, title: 'Active' }),
createEpisode({ id: '104', episode_num: 4, title: 'Paused' }),
createEpisode({ id: '105', episode_num: 5, title: 'Local' }),
createEpisode({ id: '106', episode_num: 6, title: 'Unknown' }),
createEpisode({ id: '107', episode_num: 7, title: 'N/A' }),
createEpisode({ id: '108', episode_num: 8, title: 'Failed' }),
createEpisode({ id: '109', episode_num: 9, title: 'Canceled' }),
createEpisode({ id: '110', episode_num: 10, title: 'Missing' }),
createEpisode({ id: '111', episode_num: 11, title: 'New' }),
createEpisode({
id: 'invalid',
episode_num: 12,
title: 'Invalid',
}),
];
downloadsServiceStub.downloads.set([
createDownload(episodes[1], { status: 'queued' }),
createDownload(episodes[2], { status: 'downloading' }),
createDownload(episodes[3], { status: 'paused' }),
createDownload(episodes[4], {
status: 'completed',
fileAvailability: 'available',
filePath: '/downloads/local.mp4',
}),
createDownload(episodes[5], { status: 'completed' }),
createDownload(episodes[6], {
status: 'completed',
fileAvailability: 'not-applicable',
}),
createDownload(episodes[7], { status: 'failed' }),
createDownload(episodes[8], { status: 'canceled' }),
createDownload(episodes[9], {
status: 'completed',
fileAvailability: 'missing',
}),
]);
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': episodes });
component.setViewMode(viewMode);
fixture.detectChanges();
episodeAction(101).click();
fixture.detectChanges();
const expected = [
[101, true, 'Adding Pilot to queue', 'downloading'],
[102, true, 'Adding Queued to queue', 'downloading'],
[103, true, 'Adding Active to queue', 'downloading'],
[104, false, 'Resume Paused', 'play_arrow'],
[105, false, 'Play local', 'folder_open'],
[106, true, 'Download Unknown', 'block'],
[107, true, 'Download N/A', 'block'],
[108, false, 'Download Failed', 'download'],
[109, false, 'Download Canceled', 'download'],
[110, false, 'Download Missing', 'download'],
[111, false, 'Download New', 'download'],
['invalid', true, 'Download Invalid', 'block'],
] as const;
for (const [id, disabled, aria, icon] of expected) {
const action = episodeAction(id);
expect(action.disabled).toBe(disabled);
expect(action.getAttribute('aria-label')).toBe(aria);
expect(action.querySelector('mat-icon')?.textContent).toContain(
icon
);
}
start.resolve({ success: true });
await fixture.whenStable();
}
);
it('resumes by managed row id and plays the coordinate-matched local file', async () => {
const paused = createEpisode();
const local = createEpisode({
id: '102',
episode_num: 2,
title: 'Local',
});
downloadsServiceStub.downloads.set([
createDownload(paused, {
id: 71,
xtreamId: 9001,
status: 'paused',
}),
createDownload(local, {
id: 72,
xtreamId: 9002,
status: 'completed',
fileAvailability: 'available',
filePath: '/authorized/downloads/local.mp4',
}),
]);
enableDownloads();
setRequiredInputs({ '1': [paused, local] });
fixture.detectChanges();
episodeAction(101).click();
episodeAction(102).click();
await fixture.whenStable();
expect(downloadsServiceStub.resumeDownload).toHaveBeenCalledWith(71);
expect(downloadsServiceStub.playDownload).toHaveBeenCalledWith(
'/authorized/downloads/local.mp4'
);
});
it('dispatches one submission for rapid duplicate episode clicks without success feedback', async () => {
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
episodeAction(101).click();
episodeAction(101).click();
await Promise.resolve();
await Promise.resolve();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
start.resolve({ success: true });
await fixture.whenStable();
expect(snackBarOpen).not.toHaveBeenCalled();
});
it('dispatches the selected season only once for rapid duplicate clicks', async () => {
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
seasonButton.click();
await Promise.resolve();
await Promise.resolve();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
start.resolve({ success: true });
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledTimes(1);
});
it('shows only the localized generic episode error and no added feedback', async () => {
downloadsServiceStub.startDownload.mockResolvedValueOnce({
success: false,
error: 'https://portal.test?username=alice&password=hunter2',
});
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
episodeAction(101).click();
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledWith(
'The episode could not be added',
undefined,
{ duration: 5000 }
);
expect(JSON.stringify(snackBarOpen.mock.calls)).not.toContain('alice');
expect(JSON.stringify(snackBarOpen.mock.calls)).not.toContain(
'hunter2'
);
expect(JSON.stringify(snackBarOpen.mock.calls)).not.toContain(
'portal.test'
);
snackBarOpen.mockClear();
downloadsServiceStub.startDownload.mockResolvedValueOnce({
success: true,
});
episodeAction(101).click();
await fixture.whenStable();
expect(snackBarOpen).not.toHaveBeenCalled();
});
it('snapshots the selected season before asynchronous preparation', async () => {
const preparation = deferred<void>();
const first = createEpisode();
const second = createEpisode({
id: '102',
episode_num: 2,
title: 'Second',
});
const delayedAdapter: SeasonEpisodeDownloadAdapter = {
createCandidate(episode, seasonKey) {
const candidate = downloadAdapter.createCandidate(
episode,
seasonKey
);
if (!candidate || episode.id !== '101') {
return candidate;
}
return {
...candidate,
prepare: async () => {
await preparation.promise;
return candidate.prepare();
},
};
},
};
enableDownloads(delayedAdapter);
setRequiredInputs({
'1': [first, second],
'2': [createEpisode({ id: '201', season: 2 })],
});
component.selectSeason('1');
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
component.selectSeason('2');
fixture.componentRef.setInput('seasons', {
'1': [createEpisode({ id: '103', title: 'Replacement' })],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
preparation.resolve(undefined);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await fixture.whenStable();
expect(
downloadsServiceStub.startDownload.mock.calls.map(
([request]) => request.xtreamId
)
).toEqual([101, 102]);
});
it('uses the failure aggregate key when any season submission fails', async () => {
downloadsServiceStub.startDownload
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'rejected' });
enableDownloads();
setRequiredInputs({
'1': [
createEpisode(),
createEpisode({ id: '102', episode_num: 2 }),
],
});
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement
).click();
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledWith(
'Queue failed: 1 added, 0 skipped, 1 failed',
undefined,
{ duration: 5000 }
);
});
});
@@ -16,8 +16,15 @@ import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
EPISODE_DOWNLOAD_SUBMISSIONS,
SeasonDownloadCoordinator,
type EpisodeDownloadCandidate,
type SeasonEpisodeDownloadAdapter,
} from '@iptvnator/portal/shared/data-access';
import {
createLogger,
getPortalPlaybackProgressPercent,
@@ -29,14 +36,8 @@ import {
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import { DownloadsService } from '@iptvnator/services';
import { DownloadsService, type DownloadItem } from '@iptvnator/services';
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
import {
buildXtreamEpisodeDownloadRequest,
getEpisodeDownloadId,
isStalkerEpisode,
type SeasonContainerDownloadMetadataContext,
} from './episode-download.util';
import {
EPISODE_INFO_PLAY,
EpisodeInfoDialogComponent,
@@ -51,13 +52,59 @@ import { SeasonTabsComponent } from './season-tabs.component';
type EpisodeViewMode = 'grid' | 'list';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
export interface SeasonContainerXtreamDownloadContext {
serverUrl?: string;
username?: string;
password?: string;
const EPISODE_DOWNLOAD_ACTIONS = {
Blocked: 'blocked',
Download: 'download',
Pending: 'pending',
Play: 'play',
Resume: 'resume',
} as const;
type EpisodeDownloadAction =
(typeof EPISODE_DOWNLOAD_ACTIONS)[keyof typeof EPISODE_DOWNLOAD_ACTIONS];
interface EpisodeDownloadState {
readonly action: EpisodeDownloadAction;
readonly ariaKey: string;
readonly disabled: boolean;
readonly icon: string;
}
export type { SeasonContainerDownloadMetadataContext } from './episode-download.util';
const EPISODE_DOWNLOAD_STATES: Record<
EpisodeDownloadAction,
EpisodeDownloadState
> = {
blocked: {
action: EPISODE_DOWNLOAD_ACTIONS.Blocked,
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
disabled: true,
icon: 'block',
},
download: {
action: EPISODE_DOWNLOAD_ACTIONS.Download,
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
disabled: false,
icon: 'download',
},
pending: {
action: EPISODE_DOWNLOAD_ACTIONS.Pending,
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_PENDING_ARIA',
disabled: true,
icon: 'downloading',
},
play: {
action: EPISODE_DOWNLOAD_ACTIONS.Play,
ariaKey: 'DOWNLOADS.PLAY_LOCAL',
disabled: false,
icon: 'folder_open',
},
resume: {
action: EPISODE_DOWNLOAD_ACTIONS.Resume,
ariaKey: 'DOWNLOADS.ARIA.RESUME',
disabled: false,
icon: 'play_arrow',
},
};
export interface SeasonContainerPlaybackToggleRequest {
contentXtreamId: number;
@@ -82,8 +129,11 @@ export interface SeasonContainerPlaybackToggleRequest {
})
export class SeasonContainerComponent implements OnInit {
private readonly downloadsService = inject(DownloadsService);
private readonly downloadCoordinator = inject(SeasonDownloadCoordinator);
private readonly dialog = inject(MatDialog);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('SeasonContainer');
private lastEmittedSeason: string | undefined;
@@ -95,10 +145,7 @@ export class SeasonContainerComponent implements OnInit {
readonly playbackPositions = input<Map<number, PlaybackPositionData>>(
new Map()
);
readonly xtreamDownloadContext =
input<SeasonContainerXtreamDownloadContext | null>(null);
readonly downloadMetadataContext =
input<SeasonContainerDownloadMetadataContext | null>(null);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
/** Episode currently playing in an EXTERNAL player session. */
@@ -109,12 +156,50 @@ export class SeasonContainerComponent implements OnInit {
readonly seasonDescriptions = input<Record<string, string> | null>(null);
readonly episodeClicked = output<XtreamSerieEpisode>();
readonly episodeDownloadRequested = output<XtreamSerieEpisode>();
readonly playbackToggleRequested =
output<SeasonContainerPlaybackToggleRequest>();
readonly seasonSelected = output<string>();
readonly isElectron = this.downloadsService.isAvailable;
readonly viewMode = signal<EpisodeViewMode>('grid');
readonly batchRunning = signal(false);
readonly downloadPresentationVisible = computed(
() =>
this.downloadsService.isAvailable() &&
this.downloadsEnabled() &&
this.downloadAdapter() !== null
);
readonly eligibleEpisodeCount = computed(() => {
const adapter = this.downloadAdapter();
const seasonKey = this.selectedSeason();
if (
!adapter ||
!seasonKey ||
!this.downloadsService.hasLoadedDownloads()
) {
return 0;
}
return this.selectedSeasonEpisodes().reduce((count, episode) => {
const candidate = adapter.createCandidate(episode, seasonKey);
return (
count +
(candidate && this.downloadCoordinator.isEligible(candidate)
? 1
: 0)
);
}, 0);
});
readonly seasonDownloadDisabled = computed(
() =>
this.isLoading() ||
this.batchRunning() ||
!this.downloadsService.hasLoadedDownloads() ||
!this.selectedSeason() ||
this.selectedSeasonEpisodes().length === 0 ||
this.eligibleEpisodeCount() === 0
);
readonly sortedSeasonKeys = computed(() =>
Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b))
@@ -366,106 +451,152 @@ export class SeasonContainerComponent implements OnInit {
return Number(episode.id);
}
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.downloadsEnabled()) return;
if (isStalkerEpisode(episode)) {
this.episodeDownloadRequested.emit(episode);
return;
}
const xtreamDownload = this.xtreamDownloadContext();
if (!this.playlistId() || !xtreamDownload) {
return;
}
await this.downloadsService.startDownload(
buildXtreamEpisodeDownloadRequest({
candidateFor(episode: XtreamSerieEpisode): EpisodeDownloadCandidate | null {
return (
this.downloadAdapter()?.createCandidate(
episode,
context: xtreamDownload,
playlistId: this.playlistId(),
seriesId: this.seriesId(),
seriesTitle: this.seriesTitle(),
fallbackSeasonKey: this.selectedSeason(),
posterUrl: this.getEpisodeInfo(episode)?.movie_image,
metadataContext: this.downloadMetadataContext() ?? undefined,
})
this.selectedSeason()
) ?? null
);
}
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
downloadFor(episode: XtreamSerieEpisode): DownloadItem | undefined {
const candidate = this.candidateFor(episode);
return candidate
? this.downloadCoordinator.findDownload(candidate.identity)
: undefined;
}
episodeDownloadState(episode: XtreamSerieEpisode): EpisodeDownloadState {
const candidate = this.candidateFor(episode);
if (!candidate || !this.downloadsService.hasLoadedDownloads()) {
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Blocked);
}
if (this.downloadCoordinator.isPending(candidate.identity)) {
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Pending);
}
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
const download = this.downloadCoordinator.findDownload(
candidate.identity
);
}
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
if (
download?.status === 'queued' ||
download?.status === 'downloading'
) {
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Pending);
}
if (download?.status === 'paused') {
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Resume);
}
if (download?.status === 'completed') {
if (
download.fileAvailability === 'available' &&
download.filePath
) {
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Play);
}
return this.downloadState(
download.fileAvailability === 'missing'
? EPISODE_DOWNLOAD_ACTIONS.Download
: EPISODE_DOWNLOAD_ACTIONS.Blocked
);
}
this.downloadsService.downloads();
return this.downloadsService.isDownloading(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Download);
}
isEpisodePaused(episode: XtreamSerieEpisode): boolean {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
this.downloadsService.downloads();
return this.downloadsService.isPaused(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
async resumeEpisodeDownload(
runEpisodeDownloadAction(
event: Event,
episode: XtreamSerieEpisode
): Promise<void> {
episode: XtreamSerieEpisode,
state: EpisodeDownloadState
): void {
event.stopPropagation();
if (!this.playlistId()) {
if (state.disabled) {
return;
}
await this.downloadsService.resumeDownloadByContent(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
if (state.action === EPISODE_DOWNLOAD_ACTIONS.Resume) {
void this.resumeEpisodeDownload(episode);
} else if (state.action === EPISODE_DOWNLOAD_ACTIONS.Play) {
void this.playFromLocal(episode);
} else if (state.action === EPISODE_DOWNLOAD_ACTIONS.Download) {
void this.downloadEpisode(episode);
}
}
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.playlistId()) {
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
if (!this.downloadPresentationVisible()) {
return;
}
const candidate = this.candidateFor(episode);
if (!candidate) {
return;
}
const filePath = this.downloadsService.getDownloadedFilePath(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
const result = await this.downloadCoordinator.enqueueOne(candidate);
if (result === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
this.snackBar.open(
this.translate.instant('DOWNLOADS.EPISODE_DOWNLOAD_FAILED'),
undefined,
{ duration: 5000 }
);
}
}
async downloadSelectedSeason(): Promise<void> {
const adapter = this.downloadAdapter();
const seasonKey = this.selectedSeason();
if (
this.batchRunning() ||
this.seasonDownloadDisabled() ||
!this.downloadPresentationVisible() ||
!adapter ||
!seasonKey
) {
return;
}
const episodes = [...this.selectedSeasonEpisodes()];
const candidates = episodes.map((episode) =>
adapter.createCandidate(episode, seasonKey)
);
this.batchRunning.set(true);
try {
const result =
await this.downloadCoordinator.enqueueSeason(candidates);
const key =
result.failed > 0
? 'DOWNLOADS.SEASON_QUEUE_RESULT_WITH_FAILURES'
: 'DOWNLOADS.SEASON_QUEUE_RESULT';
this.snackBar.open(this.translate.instant(key, result), undefined, {
duration: 5000,
});
} finally {
this.batchRunning.set(false);
}
}
async resumeEpisodeDownload(episode: XtreamSerieEpisode): Promise<void> {
const download = this.downloadFor(episode);
if (download?.status === 'paused') {
await this.downloadsService.resumeDownload(download.id);
}
}
async playFromLocal(episode: XtreamSerieEpisode): Promise<void> {
const download = this.downloadFor(episode);
if (
download?.status === 'completed' &&
download.fileAvailability === 'available' &&
download.filePath
) {
await this.downloadsService.playDownload(download.filePath);
}
}
private downloadState(action: EpisodeDownloadAction): EpisodeDownloadState {
return EPISODE_DOWNLOAD_STATES[action];
}
private getEpisodePosition(
episode: XtreamSerieEpisode
): PlaybackPositionData | undefined {