mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
feat(downloads): queue season episode downloads (#1357)
* docs(downloads): specify season queueing * docs(downloads): plan season queue implementation * feat(downloads): define episode queue identity * fix(downloads): align episode identity contract * feat(downloads): coordinate season queue submissions * fix(downloads): keep queue coordination provider neutral * fix(downloads): reconcile legacy episode identities * fix(downloads): fail closed on invalid stored coordinates * refactor(downloads): adapt Xtream episode requests * fix(downloads): use canonical Stalker episode ids * test(downloads): cover Stalker adapter reactivity * feat(downloads): add selected season queue action * refactor(downloads): extract season download presenter * feat(downloads): localize season queue feedback * test(downloads): cover series batch queue flow * test(downloads): harden series queue fixtures * docs(downloads): describe season queueing * docs(downloads): clarify season queue IPC contract * fix(downloads): isolate season header build warnings * fix(downloads): label season view toggles * fix(downloads): preserve Xtream episode headers * fix(downloads): fail closed on stale episode state * fix(downloads): align renderer queue safeguards * fix(downloads): block ambiguous episode actions * fix(downloads): accept nullable legacy coordinates * fix(downloads): preserve scoped episode ownership * fix(downloads): probe restored files asynchronously * fix(downloads): bound restored file probes * fix(downloads): release timed out file probes * fix(downloads): bound file probe callers * fix(downloads): refresh stable season skips * fix(downloads): fail closed before provider prep * fix(downloads): preserve retained partial ownership * fix(downloads): reconcile partial cleanup completion * fix(downloads): await authoritative list refresh * fix(downloads): coalesce list refreshes * fix(downloads): preserve specials season identity * fix(stalker): preserve specials season mapping * fix(downloads): distinguish missing Xtream seasons
This commit is contained in:
1 parent
d3cc18dc72
commit
96facd6f49
79 files changed
+8352
-1255
No files matched your search
@@ -1,178 +0,0 @@
|
||||
import {
|
||||
DownloadMetadataSnapshot,
|
||||
XTREAM_CLIENT_USER_AGENT,
|
||||
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;
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
origin?: 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;
|
||||
headers: {
|
||||
userAgent: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
};
|
||||
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,
|
||||
headers: {
|
||||
userAgent: context.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT,
|
||||
referer: context.referrer,
|
||||
origin: context.origin,
|
||||
},
|
||||
...(options.metadataContext
|
||||
? {
|
||||
metadataSnapshot: buildMetadataSnapshot(
|
||||
options.metadataContext,
|
||||
episode,
|
||||
seasonNumber,
|
||||
episodeNumber
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,190 +1,8 @@
|
||||
import {
|
||||
XTREAM_CLIENT_USER_AGENT,
|
||||
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,
|
||||
headers: {
|
||||
userAgent: XTREAM_CLIENT_USER_AGENT,
|
||||
referer: undefined,
|
||||
origin: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards playlist request headers for the first episode transfer', () => {
|
||||
const request = buildXtreamEpisodeDownloadRequest({
|
||||
episode: episode({ id: '55' }),
|
||||
context: {
|
||||
serverUrl: 'http://host',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
userAgent: 'Provider Player/1.0',
|
||||
referrer: 'https://provider.test/player',
|
||||
origin: 'https://provider.test',
|
||||
},
|
||||
playlistId: 'pl-1',
|
||||
seriesId: 900,
|
||||
seriesTitle: 'Show',
|
||||
fallbackSeasonKey: '1',
|
||||
});
|
||||
|
||||
expect(request.headers).toEqual({
|
||||
userAgent: 'Provider Player/1.0',
|
||||
referer: 'https://provider.test/player',
|
||||
origin: 'https://provider.test',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,33 +1,14 @@
|
||||
<section class="seasons-section">
|
||||
<div class="section-header">
|
||||
<div class="section-header__left">
|
||||
<h3 class="section-title">
|
||||
{{ '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"
|
||||
>
|
||||
<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>
|
||||
<app-season-header
|
||||
[downloadVisible]="downloadPresenter.presentationVisible()"
|
||||
[batchRunning]="downloadPresenter.batchRunning()"
|
||||
[seasonDisabled]="downloadPresenter.seasonDisabled()"
|
||||
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
|
||||
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
|
||||
[viewMode]="viewMode()"
|
||||
(downloadSeason)="downloadPresenter.enqueueSeason()"
|
||||
(viewModeChange)="setViewMode($event)"
|
||||
/>
|
||||
|
||||
@if (hasSeasons()) {
|
||||
<app-season-tabs
|
||||
@@ -155,73 +136,65 @@
|
||||
}}</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 (
|
||||
downloadPresenter.presentationVisible()
|
||||
) {
|
||||
@let downloadRow =
|
||||
downloadPresenter.rowFor(episode);
|
||||
@let downloadState =
|
||||
downloadRow.presentation;
|
||||
<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.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
[matTooltip]="
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
downloadPresenter.runEpisodeAction(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
>
|
||||
<mat-icon>{{
|
||||
downloadState.icon
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="episode-card__overlay">
|
||||
@@ -412,63 +385,56 @@
|
||||
}}</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 (downloadPresenter.presentationVisible()) {
|
||||
@let downloadRow =
|
||||
downloadPresenter.rowFor(episode);
|
||||
@let downloadState =
|
||||
downloadRow.presentation;
|
||||
<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.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
[matTooltip]="
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
downloadPresenter.runEpisodeAction(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
>
|
||||
<mat-icon>{{
|
||||
downloadState.icon
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (episodeInfo?.plot) {
|
||||
<button
|
||||
|
||||
@@ -3,80 +3,6 @@
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
min-height: 32px;
|
||||
gap: 16px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
mat-button-toggle-group {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
|
||||
::ng-deep {
|
||||
.mat-button-toggle {
|
||||
background: transparent;
|
||||
|
||||
.mat-button-toggle-button {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.mat-button-toggle-checked {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
mat-icon {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.mat-button-toggle-checked) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle-appearance-standard {
|
||||
.mat-button-toggle-label-content {
|
||||
line-height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,22 +1,63 @@
|
||||
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 {
|
||||
type InterpolatableTranslationObject,
|
||||
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 { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
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 hasAuthoritativeDownloadList: 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 EN_TRANSLATIONS = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(process.cwd(), 'apps/web/src/assets/i18n/en.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as InterpolatableTranslationObject;
|
||||
|
||||
const DOWNLOAD_TRANSLATIONS = {
|
||||
DOWNLOADS: EN_TRANSLATIONS['DOWNLOADS'],
|
||||
};
|
||||
|
||||
function createEpisode(
|
||||
@@ -40,26 +81,126 @@ 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.hasAuthoritativeDownloadList.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();
|
||||
localStorage.removeItem('iptvnator_episode_view_mode');
|
||||
downloadsServiceStub = {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: 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 = [];
|
||||
@@ -88,6 +239,10 @@ describe('SeasonContainerComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem('iptvnator_episode_view_mode');
|
||||
});
|
||||
|
||||
it('renders the series-level placeholder when no seasons are available', () => {
|
||||
setRequiredInputs({});
|
||||
fixture.detectChanges();
|
||||
@@ -145,53 +300,12 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.nativeElement.querySelectorAll('.episode-card').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.download-btn').length
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'[data-test-id^="episode-download-"]'
|
||||
).length
|
||||
).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',
|
||||
userAgent: 'Provider Player/1.0',
|
||||
referrer: 'https://provider.test/player',
|
||||
origin: 'https://provider.test',
|
||||
});
|
||||
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',
|
||||
headers: {
|
||||
userAgent: 'Provider Player/1.0',
|
||||
referer: 'https://provider.test/player',
|
||||
origin: 'https://provider.test',
|
||||
},
|
||||
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();
|
||||
@@ -430,4 +544,591 @@ 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(
|
||||
'Pilot is already in the download 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 season, 1 episodes available'
|
||||
);
|
||||
expect(
|
||||
button.querySelector('.season-download-button__label')?.textContent
|
||||
).toContain('Download season (1)');
|
||||
expect(button.nextElementSibling?.classList).toContain('view-toggle');
|
||||
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'Added 1 · Skipped 1',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks episode and season actions when managed rows claim an ambiguous identity', () => {
|
||||
const episode = createEpisode();
|
||||
downloadsServiceStub.downloads.set([
|
||||
createDownload(episode, { id: 71, xtreamId: 9001 }),
|
||||
createDownload(episode, { id: 72, xtreamId: 9002 }),
|
||||
]);
|
||||
enableDownloads();
|
||||
setRequiredInputs({ '1': [episode] });
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const action = episodeAction(episode.id);
|
||||
const seasonButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(action.disabled).toBe(true);
|
||||
expect(action.querySelector('mat-icon')?.textContent).toContain(
|
||||
'block'
|
||||
);
|
||||
expect(seasonButton.disabled).toBe(true);
|
||||
expect(downloadsServiceStub.startDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a failed canonical legacy row with null coordinates downloadable', () => {
|
||||
const episode = createEpisode();
|
||||
const legacy = {
|
||||
...createDownload(episode, { status: 'failed' }),
|
||||
seriesXtreamId: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
} as unknown as DownloadItem;
|
||||
downloadsServiceStub.downloads.set([legacy]);
|
||||
enableDownloads();
|
||||
setRequiredInputs({ '1': [episode] });
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const action = episodeAction(episode.id);
|
||||
const seasonButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(action.disabled).toBe(false);
|
||||
expect(action.querySelector('mat-icon')?.textContent).toContain(
|
||||
'download'
|
||||
);
|
||||
expect(seasonButton.disabled).toBe(false);
|
||||
expect(seasonButton.textContent).toContain('Download season (1)');
|
||||
});
|
||||
|
||||
it('gives the grid and list view radios localized accessible names', () => {
|
||||
TestBed.inject(TranslateService).setTranslation(
|
||||
'en',
|
||||
{
|
||||
PORTALS: {
|
||||
GRID_VIEW: 'Grid view',
|
||||
LIST_VIEW: 'List view',
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const viewModeRadios = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('button[role="radio"]')
|
||||
) as HTMLButtonElement[];
|
||||
expect(
|
||||
viewModeRadios.map((radio) => radio.getAttribute('aria-label'))
|
||||
).toEqual(['Grid view', 'List view']);
|
||||
});
|
||||
|
||||
it('disables the season action until authoritative eligible work exists and while work is in flight', async () => {
|
||||
const first = createEpisode();
|
||||
enableDownloads();
|
||||
downloadsServiceStub.hasAuthoritativeDownloadList.set(false);
|
||||
setRequiredInputs({ '1': [first] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const seasonButton = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(seasonButton().disabled).toBe(true);
|
||||
expect(downloadsServiceStub.hasLoadedDownloads()).toBe(true);
|
||||
expect(episodeAction(first.id).disabled).toBe(true);
|
||||
|
||||
downloadsServiceStub.hasAuthoritativeDownloadList.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('announces the season queue action as busy until submission refresh settles', async () => {
|
||||
const start = deferred<{ success: boolean }>();
|
||||
const refresh = deferred<void>();
|
||||
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
|
||||
downloadsServiceStub.loadDownloads.mockReturnValue(refresh.promise);
|
||||
enableDownloads();
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const seasonButton = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
seasonButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(seasonButton().textContent).toContain('Adding to queue…');
|
||||
expect(seasonButton().getAttribute('aria-label')).toBe(
|
||||
'Adding to queue…'
|
||||
);
|
||||
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
start.resolve({ success: true });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
refresh.resolve(undefined);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().hasAttribute('aria-busy')).toBe(false);
|
||||
expect(seasonButton().textContent).toContain('Download season (1)');
|
||||
expect(seasonButton().getAttribute('aria-label')).toBe(
|
||||
'Download season, 1 episodes available'
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'[data-test-id^="episode-download-"]'
|
||||
)
|
||||
).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(
|
||||
'[data-test-id^="episode-download-"]'
|
||||
)
|
||||
).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,
|
||||
'Pilot is already in the download queue',
|
||||
'downloading',
|
||||
],
|
||||
[
|
||||
102,
|
||||
true,
|
||||
'Queued is already in the download queue',
|
||||
'downloading',
|
||||
],
|
||||
[
|
||||
103,
|
||||
true,
|
||||
'Active is already in the download queue',
|
||||
'downloading',
|
||||
],
|
||||
[104, false, 'Resume Paused', 'play_arrow'],
|
||||
[105, false, 'Play Local: 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('reuses the selected-season row model across rendering, resume, and local play', async () => {
|
||||
const paused = createEpisode();
|
||||
const local = createEpisode({
|
||||
id: '102',
|
||||
episode_num: 2,
|
||||
title: 'Local',
|
||||
});
|
||||
downloadsServiceStub.downloads.set([
|
||||
createDownload(paused, { id: 71, status: 'paused' }),
|
||||
createDownload(local, {
|
||||
id: 72,
|
||||
status: 'completed',
|
||||
fileAvailability: 'available',
|
||||
filePath: '/authorized/downloads/local.mp4',
|
||||
}),
|
||||
]);
|
||||
const candidateSpy = jest.spyOn(downloadAdapter, 'createCandidate');
|
||||
const coordinator = TestBed.inject(SeasonDownloadCoordinator);
|
||||
const resolveDownloadSpy = jest.spyOn(coordinator, 'resolveDownload');
|
||||
enableDownloads();
|
||||
setRequiredInputs({ '1': [paused, local] });
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
expect(candidateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(resolveDownloadSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
episodeAction(101).click();
|
||||
episodeAction(102).click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(candidateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(resolveDownloadSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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 to downloads.',
|
||||
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(
|
||||
'Added 1 · Skipped 0 · Failed 1',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
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 { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
createLogger,
|
||||
getPortalPlaybackProgressPercent,
|
||||
@@ -29,15 +29,7 @@ import {
|
||||
XtreamSerieEpisode,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
|
||||
import {
|
||||
buildXtreamEpisodeDownloadRequest,
|
||||
getEpisodeDownloadId,
|
||||
isStalkerEpisode,
|
||||
type SeasonContainerDownloadMetadataContext,
|
||||
type XtreamEpisodeDownloadContext,
|
||||
} from './episode-download.util';
|
||||
import {
|
||||
EPISODE_INFO_PLAY,
|
||||
EpisodeInfoDialogComponent,
|
||||
@@ -47,15 +39,15 @@ import {
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import {
|
||||
type EpisodeViewMode,
|
||||
SeasonHeaderComponent,
|
||||
} from './season-header.component';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
|
||||
type EpisodeViewMode = 'grid' | 'list';
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
export type SeasonContainerXtreamDownloadContext = XtreamEpisodeDownloadContext;
|
||||
|
||||
export type { SeasonContainerDownloadMetadataContext } from './episode-download.util';
|
||||
|
||||
export interface SeasonContainerPlaybackToggleRequest {
|
||||
contentXtreamId: number;
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
@@ -66,23 +58,24 @@ export interface SeasonContainerPlaybackToggleRequest {
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [SeasonDownloadPresenter],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
ProgressCapsuleComponent,
|
||||
SeasonHeaderComponent,
|
||||
SeasonTabsComponent,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class SeasonContainerComponent implements OnInit {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private lastEmittedSeason: string | undefined;
|
||||
readonly downloadPresenter = inject(SeasonDownloadPresenter);
|
||||
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly seriesId = input.required<number>();
|
||||
@@ -92,10 +85,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. */
|
||||
@@ -106,11 +96,9 @@ 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 sortedSeasonKeys = computed(() =>
|
||||
@@ -150,6 +138,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
*/
|
||||
readonly selectedSeason = signal<string | undefined>(undefined);
|
||||
|
||||
readonly selectedSeasonEpisodes = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
});
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
@@ -184,6 +177,14 @@ export class SeasonContainerComponent implements OnInit {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.downloadPresenter.connect({
|
||||
adapter: this.downloadAdapter,
|
||||
downloadsEnabled: this.downloadsEnabled,
|
||||
isLoading: this.isLoading,
|
||||
selectedEpisodes: this.selectedSeasonEpisodes,
|
||||
selectedSeason: this.selectedSeason,
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const key = this.autoSelectKey();
|
||||
if (key === this.lastAutoSelectKey) {
|
||||
@@ -234,11 +235,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
selectedSeasonEpisodes(): XtreamSerieEpisode[] {
|
||||
const selected = this.selectedSeason();
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason.set(seasonKey);
|
||||
}
|
||||
@@ -363,106 +359,6 @@ 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({
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
|
||||
if (!this.downloadsEnabled() || !this.playlistId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloaded(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
}
|
||||
|
||||
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
|
||||
if (!this.downloadsEnabled() || !this.playlistId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloading(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
event: Event,
|
||||
episode: XtreamSerieEpisode
|
||||
): Promise<void> {
|
||||
event.stopPropagation();
|
||||
if (!this.playlistId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
}
|
||||
|
||||
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
if (!this.playlistId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = this.downloadsService.getDownloadedFilePath(
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
|
||||
if (filePath) {
|
||||
await this.downloadsService.playDownload(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private getEpisodePosition(
|
||||
episode: XtreamSerieEpisode
|
||||
): PlaybackPositionData | undefined {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
Injectable,
|
||||
type Signal,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EPISODE_DOWNLOAD_SUBMISSIONS,
|
||||
SeasonDownloadCoordinator,
|
||||
type EpisodeDownloadCandidate,
|
||||
type SeasonEpisodeDownloadAdapter,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import { isEpisodeDownloadEligible } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService, type DownloadItem } from '@iptvnator/services';
|
||||
|
||||
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];
|
||||
|
||||
export interface EpisodeDownloadPresentation {
|
||||
readonly action: EpisodeDownloadAction;
|
||||
readonly ariaKey: string;
|
||||
readonly disabled: boolean;
|
||||
readonly icon: string;
|
||||
}
|
||||
|
||||
export interface SeasonEpisodeDownloadRow {
|
||||
readonly candidate: EpisodeDownloadCandidate | null;
|
||||
readonly download: DownloadItem | undefined;
|
||||
readonly eligible: boolean;
|
||||
readonly episode: XtreamSerieEpisode;
|
||||
readonly presentation: EpisodeDownloadPresentation;
|
||||
}
|
||||
|
||||
export interface SeasonDownloadPresenterSources {
|
||||
readonly adapter: Signal<SeasonEpisodeDownloadAdapter | null>;
|
||||
readonly downloadsEnabled: Signal<boolean>;
|
||||
readonly isLoading: Signal<boolean>;
|
||||
readonly selectedEpisodes: Signal<readonly XtreamSerieEpisode[]>;
|
||||
readonly selectedSeason: Signal<string | undefined>;
|
||||
}
|
||||
|
||||
const EPISODE_DOWNLOAD_STATES: Record<
|
||||
EpisodeDownloadAction,
|
||||
EpisodeDownloadPresentation
|
||||
> = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SeasonDownloadPresenter {
|
||||
private readonly coordinator = inject(SeasonDownloadCoordinator);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly sources = signal<SeasonDownloadPresenterSources | null>(
|
||||
null
|
||||
);
|
||||
private readonly batchInFlight = signal(false);
|
||||
|
||||
readonly batchRunning = this.batchInFlight.asReadonly();
|
||||
readonly presentationVisible = computed(() => {
|
||||
const sources = this.sources();
|
||||
return Boolean(
|
||||
sources &&
|
||||
this.downloadsService.isAvailable() &&
|
||||
sources.downloadsEnabled() &&
|
||||
sources.adapter()
|
||||
);
|
||||
});
|
||||
|
||||
readonly rows = computed<readonly SeasonEpisodeDownloadRow[]>(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const adapter = sources.adapter();
|
||||
const seasonKey = sources.selectedSeason();
|
||||
const downloadsAvailable = this.downloadsService.isAvailable();
|
||||
const downloadsReady =
|
||||
this.downloadsService.hasLoadedDownloads() &&
|
||||
this.downloadsService.hasAuthoritativeDownloadList();
|
||||
this.downloadsService.downloads();
|
||||
|
||||
return sources.selectedEpisodes().map((episode) => {
|
||||
const candidate =
|
||||
adapter && seasonKey
|
||||
? adapter.createCandidate(episode, seasonKey)
|
||||
: null;
|
||||
const pending = candidate
|
||||
? this.coordinator.isPending(candidate.identity)
|
||||
: false;
|
||||
const resolution = candidate
|
||||
? this.coordinator.resolveDownload(candidate.identity)
|
||||
: { kind: 'missing' as const };
|
||||
const download =
|
||||
resolution.kind === 'match' ? resolution.download : undefined;
|
||||
const identityConflict = resolution.kind === 'conflict';
|
||||
return {
|
||||
candidate,
|
||||
download,
|
||||
eligible: Boolean(
|
||||
candidate &&
|
||||
downloadsAvailable &&
|
||||
downloadsReady &&
|
||||
!pending &&
|
||||
isEpisodeDownloadEligible(resolution)
|
||||
),
|
||||
episode,
|
||||
presentation: this.createPresentation(
|
||||
candidate,
|
||||
download,
|
||||
pending,
|
||||
downloadsReady,
|
||||
identityConflict
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
private readonly rowByEpisode = computed(
|
||||
() => new Map(this.rows().map((row) => [row.episode, row] as const))
|
||||
);
|
||||
|
||||
readonly eligibleEpisodeCount = computed(
|
||||
() => this.rows().filter((row) => row.eligible).length
|
||||
);
|
||||
|
||||
readonly seasonDisabled = computed(() => {
|
||||
const sources = this.sources();
|
||||
return (
|
||||
!sources ||
|
||||
sources.isLoading() ||
|
||||
this.batchRunning() ||
|
||||
!this.downloadsService.hasLoadedDownloads() ||
|
||||
!this.downloadsService.hasAuthoritativeDownloadList() ||
|
||||
!sources.selectedSeason() ||
|
||||
this.rows().length === 0 ||
|
||||
this.eligibleEpisodeCount() === 0
|
||||
);
|
||||
});
|
||||
|
||||
connect(sources: SeasonDownloadPresenterSources): void {
|
||||
if (this.sources()) {
|
||||
throw new Error('SeasonDownloadPresenter is already connected');
|
||||
}
|
||||
this.sources.set(sources);
|
||||
}
|
||||
|
||||
rowFor(episode: XtreamSerieEpisode): SeasonEpisodeDownloadRow {
|
||||
const row = this.rowByEpisode().get(episode);
|
||||
if (!row) {
|
||||
throw new Error('Episode is outside the selected-season row model');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
runEpisodeAction(event: Event, episode: XtreamSerieEpisode): void {
|
||||
event.stopPropagation();
|
||||
const row = this.rowByEpisode().get(episode);
|
||||
if (!row || row.presentation.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Resume) {
|
||||
void this.resume(row);
|
||||
} else if (row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Play) {
|
||||
void this.play(row);
|
||||
} else if (
|
||||
row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Download
|
||||
) {
|
||||
void this.enqueueOne(episode);
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueOne(episode: XtreamSerieEpisode): Promise<void> {
|
||||
if (!this.presentationVisible()) {
|
||||
return;
|
||||
}
|
||||
const row = this.rowByEpisode().get(episode);
|
||||
if (!row?.candidate || !row.eligible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.coordinator.enqueueOne(row.candidate);
|
||||
if (result === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
|
||||
this.notify('DOWNLOADS.EPISODE_DOWNLOAD_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueSeason(): Promise<void> {
|
||||
if (
|
||||
this.batchRunning() ||
|
||||
this.seasonDisabled() ||
|
||||
!this.presentationVisible()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = this.rows().map((row) => row.candidate);
|
||||
this.batchInFlight.set(true);
|
||||
try {
|
||||
const result = await this.coordinator.enqueueSeason(candidates);
|
||||
this.notify(
|
||||
result.failed > 0
|
||||
? 'DOWNLOADS.SEASON_QUEUE_RESULT_WITH_FAILURES'
|
||||
: 'DOWNLOADS.SEASON_QUEUE_RESULT',
|
||||
result
|
||||
);
|
||||
} finally {
|
||||
this.batchInFlight.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async resume(row: SeasonEpisodeDownloadRow): Promise<void> {
|
||||
const download = row.download;
|
||||
if (
|
||||
download?.status === 'paused' &&
|
||||
Number.isSafeInteger(download.id) &&
|
||||
download.id > 0
|
||||
) {
|
||||
await this.downloadsService.resumeDownload(download.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async play(row: SeasonEpisodeDownloadRow): Promise<void> {
|
||||
const download = row.download;
|
||||
if (
|
||||
download?.status === 'completed' &&
|
||||
download.fileAvailability === 'available' &&
|
||||
download.filePath
|
||||
) {
|
||||
await this.downloadsService.playDownload(download.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private notify(key: string, params?: object): void {
|
||||
this.snackBar.open(this.translate.instant(key, params), undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
private createPresentation(
|
||||
candidate: EpisodeDownloadCandidate | null,
|
||||
download: DownloadItem | undefined,
|
||||
pending: boolean,
|
||||
downloadsReady: boolean,
|
||||
identityConflict: boolean
|
||||
): EpisodeDownloadPresentation {
|
||||
if (!candidate || !downloadsReady || identityConflict) {
|
||||
return EPISODE_DOWNLOAD_STATES.blocked;
|
||||
}
|
||||
if (
|
||||
pending ||
|
||||
download?.status === 'queued' ||
|
||||
download?.status === 'downloading'
|
||||
) {
|
||||
return EPISODE_DOWNLOAD_STATES.pending;
|
||||
}
|
||||
if (download?.status === 'paused') {
|
||||
return EPISODE_DOWNLOAD_STATES.resume;
|
||||
}
|
||||
if (download?.status === 'completed') {
|
||||
if (
|
||||
download.fileAvailability === 'available' &&
|
||||
download.filePath
|
||||
) {
|
||||
return EPISODE_DOWNLOAD_STATES.play;
|
||||
}
|
||||
return download.fileAvailability === 'missing'
|
||||
? EPISODE_DOWNLOAD_STATES.download
|
||||
: EPISODE_DOWNLOAD_STATES.blocked;
|
||||
}
|
||||
return EPISODE_DOWNLOAD_STATES.download;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<div class="section-header">
|
||||
<div class="section-header__left">
|
||||
<h3 class="section-title">
|
||||
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
@if (downloadVisible() || viewToggleVisible()) {
|
||||
<div class="section-header__actions">
|
||||
@if (downloadVisible()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="season-download-button"
|
||||
data-test-id="download-season"
|
||||
[disabled]="seasonDisabled()"
|
||||
[attr.aria-label]="
|
||||
batchRunning()
|
||||
? ('DOWNLOADS.ADDING_TO_QUEUE' | translate)
|
||||
: ('DOWNLOADS.DOWNLOAD_SEASON_ARIA'
|
||||
| translate: { count: eligibleEpisodeCount() })
|
||||
"
|
||||
[attr.aria-busy]="batchRunning() ? 'true' : null"
|
||||
(click)="downloadSeason.emit()"
|
||||
>
|
||||
@if (batchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>download</mat-icon>
|
||||
}
|
||||
<span class="season-download-button__label">
|
||||
@if (batchRunning()) {
|
||||
{{ 'DOWNLOADS.ADDING_TO_QUEUE' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
'DOWNLOADS.DOWNLOAD_SEASON'
|
||||
| translate
|
||||
: { count: eligibleEpisodeCount() }
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (viewToggleVisible()) {
|
||||
<div class="view-toggle">
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
(change)="viewModeChange.emit($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-button-toggle
|
||||
value="grid"
|
||||
[aria-label]="'PORTALS.GRID_VIEW' | translate"
|
||||
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle
|
||||
value="list"
|
||||
[aria-label]="'PORTALS.LIST_VIEW' | translate"
|
||||
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>view_list</mat-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
min-height: 32px;
|
||||
gap: 16px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
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;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
mat-button-toggle-group {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
|
||||
::ng-deep {
|
||||
.mat-button-toggle {
|
||||
background: transparent;
|
||||
|
||||
.mat-button-toggle-button {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.mat-button-toggle-checked {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
mat-icon {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.mat-button-toggle-checked) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle-appearance-standard {
|
||||
.mat-button-toggle-label-content {
|
||||
line-height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.season-download-button {
|
||||
min-height: 36px;
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
export type EpisodeViewMode = 'grid' | 'list';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-header',
|
||||
templateUrl: './season-header.component.html',
|
||||
styleUrls: ['./season-header.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class SeasonHeaderComponent {
|
||||
readonly downloadVisible = input.required<boolean>();
|
||||
readonly batchRunning = input.required<boolean>();
|
||||
readonly seasonDisabled = input.required<boolean>();
|
||||
readonly eligibleEpisodeCount = input.required<number>();
|
||||
readonly viewToggleVisible = input.required<boolean>();
|
||||
readonly viewMode = input.required<EpisodeViewMode>();
|
||||
|
||||
readonly downloadSeason = output<void>();
|
||||
readonly viewModeChange = output<EpisodeViewMode>();
|
||||
}
|
||||
Reference in new issue
Block a user