feat(downloads): redesign download manager (#1313)

* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 18:09:31 +02:00
1 parent 46c58f4f57
commit 760099358b
188 files changed
+31245 -2281

No files matched your search

@@ -1,8 +1,13 @@
<!-- Hero Section with Backdrop -->
<section class="hero">
<!-- Back Button -->
<button class="hero__back-button" (click)="onBack()">
<mat-icon>arrow_back</mat-icon>
<button
class="hero__back-button"
type="button"
[attr.aria-label]="backLabel() || ('BACK' | translate)"
(click)="onBack()"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
@if (errorMessage()) {
<div class="hero__content hero__error">
@@ -91,17 +96,21 @@
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="
!backdropUrl() && posterUrl() && !posterError()
"
[class.hero__backdrop--blurred]="usesPosterBackdrop()"
[style.backgroundImage]="
backdropUrl() && !posterError()
? 'url(' + backdropUrl() + ')'
: posterUrl() && !posterError()
? 'url(' + posterUrl() + ')'
: fallbackBackdropBackground()
backdropImageUrl() ? null : fallbackBackdropBackground()
"
></div>
>
@if (backdropImageUrl(); as backdropImage) {
<img
class="hero__backdrop-image"
[src]="backdropImage"
(error)="onBackdropError()"
alt=""
aria-hidden="true"
/>
}
</div>
<!-- Gradient Vignette Overlay -->
<div class="hero__vignette"></div>
@@ -104,6 +104,14 @@
z-index: 0;
overflow: hidden;
&-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
// Fallback blur effect when using poster as backdrop
&--blurred {
filter: blur(40px) saturate(1.3);
@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from './content-hero.component';
describe('ContentHeroComponent', () => {
@@ -20,6 +20,9 @@ describe('ContentHeroComponent', () => {
}).compileComponents();
fixture = TestBed.createComponent(ContentHeroComponent);
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', { BACK: 'Go back' });
translate.use('en');
});
it('renders description content when ResizeObserver is unavailable', () => {
@@ -38,4 +41,89 @@ describe('ContentHeroComponent', () => {
expect(host.textContent).toContain('Fallback Title');
expect(host.textContent).toContain('Plain description');
});
it('renders an accessible button with the translated fallback back label', () => {
fixture.detectChanges();
const back = (fixture.nativeElement as HTMLElement).querySelector(
'.hero__back-button'
) as HTMLButtonElement;
expect(back.type).toBe('button');
expect(back.getAttribute('aria-label')).toBe('Go back');
});
it('uses an explicit back label when supplied', () => {
fixture.componentRef.setInput('backLabel', 'Back to downloads');
fixture.detectChanges();
const back = (fixture.nativeElement as HTMLElement).querySelector(
'.hero__back-button'
) as HTMLButtonElement;
expect(back.getAttribute('aria-label')).toBe('Back to downloads');
});
it('resets a poster failure when the poster URL changes', () => {
fixture.componentRef.setInput('posterUrl', 'broken.jpg');
fixture.detectChanges();
const first = (fixture.nativeElement as HTMLElement).querySelector(
'.poster img[src="broken.jpg"]'
) as HTMLImageElement;
first.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.poster img[src="broken.jpg"]'
)
).toBeNull();
fixture.componentRef.setInput('posterUrl', 'replacement.jpg');
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.poster img[src="replacement.jpg"]'
)
).toBeTruthy();
});
it('keeps an explicit backdrop visible when the poster image fails', () => {
fixture.componentRef.setInput('posterUrl', 'broken-poster.jpg');
fixture.componentRef.setInput('backdropUrl', 'working-backdrop.jpg');
fixture.detectChanges();
const poster = (fixture.nativeElement as HTMLElement).querySelector(
'img[src="broken-poster.jpg"]'
) as HTMLImageElement;
poster.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'img.hero__backdrop-image[src="working-backdrop.jpg"]'
)
).toBeTruthy();
});
it('falls back the backdrop independently without hiding a valid poster', () => {
fixture.componentRef.setInput('posterUrl', 'working-poster.jpg');
fixture.componentRef.setInput('backdropUrl', 'broken-backdrop.jpg');
fixture.detectChanges();
const backdrop = (fixture.nativeElement as HTMLElement).querySelector(
'img.hero__backdrop-image'
) as HTMLImageElement | null;
expect(backdrop).toBeTruthy();
backdrop?.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.poster img[src="working-poster.jpg"]'
)
).toBeTruthy();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'img.hero__backdrop-image'
)
).toBeNull();
});
});
@@ -37,17 +37,37 @@ export class ContentHeroComponent {
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
readonly backClicked = output<void>();
readonly posterError = signal(false);
private readonly failedBackdropUrl = signal<string | undefined>(undefined);
readonly backdropSourceUrl = computed(
() => this.backdropUrl() || this.posterUrl()
);
readonly backdropError = computed(() => {
const source = this.backdropSourceUrl();
return !!source && this.failedBackdropUrl() === source;
});
readonly backdropImageUrl = computed(() =>
this.backdropError() ? undefined : this.backdropSourceUrl()
);
readonly usesPosterBackdrop = computed(
() => !this.backdropUrl() && !!this.posterUrl() && !this.backdropError()
);
readonly descriptionEl = viewChild<ElementRef<HTMLElement>>('descriptionEl');
readonly descriptionEl =
viewChild<ElementRef<HTMLElement>>('descriptionEl');
readonly isDescriptionExpanded = signal(false);
readonly hasDescriptionOverflow = signal(false);
private resizeObserver?: ResizeObserver;
constructor() {
effect(() => {
this.posterUrl();
untracked(() => this.posterError.set(false));
});
effect(() => {
// Re-measure whenever description content or the element changes.
this.description();
@@ -70,6 +90,10 @@ export class ContentHeroComponent {
this.posterError.set(true);
}
onBackdropError(): void {
this.failedBackdropUrl.set(this.backdropSourceUrl());
}
readonly formattedTitle = computed(() => {
const t = this.title();
if (!t) return '';
@@ -120,7 +144,9 @@ export class ContentHeroComponent {
this.measureOverflow(el);
return;
}
this.resizeObserver = new ResizeObserver(() => this.measureOverflow(el));
this.resizeObserver = new ResizeObserver(() =>
this.measureOverflow(el)
);
this.resizeObserver.observe(el);
}
}
@@ -8,6 +8,7 @@
[backdropUrl]="backdropUrl()"
[isLoading]="isLoading()"
[errorMessage]="errorMessage()"
[backLabel]="backLabel()"
(backClicked)="backClicked.emit()"
>
<ng-container hero-tags>
@@ -21,6 +21,7 @@ import { PortalDetailShellComponent } from './portal-detail-shell.component';
[title]="'Show Title'"
[description]="'Show description'"
[posterUrl]="'poster.jpg'"
[backLabel]="'Return to downloads'"
[playbackActive]="playbackActive()"
(closePlayerRequested)="closeRequests = closeRequests + 1"
>
@@ -72,6 +73,9 @@ describe('PortalDetailShellComponent', () => {
expect(query('.details__meta .details__meta-item')).toBeTruthy();
expect(query('.action-buttons .play-btn')).toBeTruthy();
expect(query('app-content-about')).toBeNull();
expect(query('.hero__back-button')?.getAttribute('aria-label')).toBe(
'Return to downloads'
);
});
it('collapses hero and shows About with re-stamped templates in watch state', () => {
@@ -50,6 +50,7 @@ export class PortalDetailShellComponent {
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
/** True while inline playback is active — flips the layout to watch state. */
readonly playbackActive = input(false);
@@ -1,4 +1,13 @@
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
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
@@ -12,6 +21,9 @@ export interface XtreamEpisodeDownloadContext {
password?: string;
}
/** Provider-neutral metadata already loaded by the detail host. */
export type SeasonContainerDownloadMetadataContext = DownloadMovieSnapshotInput;
export interface XtreamEpisodeDownloadRequest {
playlistId: string;
xtreamId: number;
@@ -22,6 +34,42 @@ export interface XtreamEpisodeDownloadRequest {
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 {
@@ -75,6 +123,7 @@ export function buildXtreamEpisodeDownloadRequest(options: {
seriesTitle: string;
fallbackSeasonKey: string | undefined;
posterUrl?: string;
metadataContext?: SeasonContainerDownloadMetadataContext;
}): XtreamEpisodeDownloadRequest {
const { episode, context, playlistId, seriesId, seriesTitle } = options;
const serverUrl = context.serverUrl?.replace(/\/$/, '') || '';
@@ -84,7 +133,9 @@ export function buildXtreamEpisodeDownloadRequest(options: {
const seasonNumber =
episode.season || Number(options.fallbackSeasonKey) || 1;
const episodeNumber = episode.episode_num || 1;
const title = `${seriesTitle || 'Series'} - S${String(seasonNumber).padStart(
const title = `${seriesTitle || 'Series'} - S${String(
seasonNumber
).padStart(
2,
'0'
)}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`;
@@ -99,5 +150,15 @@ export function buildXtreamEpisodeDownloadRequest(options: {
seriesXtreamId: seriesId,
seasonNumber,
episodeNumber,
...(options.metadataContext
? {
metadataSnapshot: buildMetadataSnapshot(
options.metadataContext,
episode,
seasonNumber,
episodeNumber
),
}
: {}),
};
}
@@ -66,10 +66,90 @@ describe('episode-download.util', () => {
fallbackSeasonKey: '1',
});
expect(request.url).toBe('http://host/series/u/p/55.mkv');
expect(request.title).toBe('Show - S02E03 - The One');
expect(request.seasonNumber).toBe(2);
expect(request.seriesXtreamId).toBe(900);
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',
},
})
);
});
});
@@ -155,7 +155,7 @@
}}</mat-icon>
</button>
<div class="episode-card__actions">
@if (isElectron()) {
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
@@ -173,9 +173,7 @@
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (
isEpisodePaused(episode)
) {
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="download-btn download-btn--paused"
@@ -260,10 +258,7 @@
class="episode-card__info-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo(
$event,
episode
)
openEpisodeInfo($event, episode)
"
[matTooltip]="
'PORTALS.EPISODE_INFO'
@@ -417,7 +412,7 @@
}}</mat-icon>
}
</button>
@if (isElectron()) {
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
@@ -12,7 +12,7 @@ import { SeasonContainerComponent } from './season-container.component';
const downloadsServiceStub = {
isAvailable: signal(false),
downloads: () => [],
startDownload: async () => undefined,
startDownload: jest.fn().mockResolvedValue(undefined),
isDownloaded: () => false,
isDownloading: () => false,
getDownloadedFilePath: () => '',
@@ -58,6 +58,8 @@ describe('SeasonContainerComponent', () => {
};
beforeEach(async () => {
downloadsServiceStub.isAvailable.set(false);
downloadsServiceStub.startDownload.mockClear();
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
@@ -132,6 +134,56 @@ describe('SeasonContainerComponent', () => {
).toBe(2);
});
it('keeps episodes playable while download presentation is disabled', () => {
downloadsServiceStub.isAvailable.set(true);
fixture.componentRef.setInput('downloadsEnabled', false);
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
expect(
fixture.nativeElement.querySelectorAll('.episode-card').length
).toBe(1);
expect(
fixture.nativeElement.querySelectorAll('.download-btn').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',
});
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();
@@ -149,9 +201,8 @@ describe('SeasonContainerComponent', () => {
});
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
);
const pills =
fixture.nativeElement.querySelectorAll('.season-tabs__pill');
(pills[1] as HTMLButtonElement).click();
fixture.detectChanges();
@@ -220,9 +271,8 @@ describe('SeasonContainerComponent', () => {
});
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
);
const pills =
fixture.nativeElement.querySelectorAll('.season-tabs__pill');
(pills[1] as HTMLButtonElement).click();
fixture.detectChanges();
expect(component.selectedSeason()).toBe('2');
@@ -272,9 +322,8 @@ describe('SeasonContainerComponent', () => {
)
).toBeNull();
const pills = fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
);
const pills =
fixture.nativeElement.querySelectorAll('.season-tabs__pill');
(pills[1] as HTMLButtonElement).click();
fixture.detectChanges();
@@ -35,6 +35,7 @@ import {
buildXtreamEpisodeDownloadRequest,
getEpisodeDownloadId,
isStalkerEpisode,
type SeasonContainerDownloadMetadataContext,
} from './episode-download.util';
import {
EPISODE_INFO_PLAY,
@@ -56,6 +57,8 @@ export interface SeasonContainerXtreamDownloadContext {
password?: string;
}
export type { SeasonContainerDownloadMetadataContext } from './episode-download.util';
export interface SeasonContainerPlaybackToggleRequest {
contentXtreamId: number;
nextPosition: PlaybackPositionData | null;
@@ -94,6 +97,9 @@ export class SeasonContainerComponent implements OnInit {
);
readonly xtreamDownloadContext =
input<SeasonContainerXtreamDownloadContext | null>(null);
readonly downloadMetadataContext =
input<SeasonContainerDownloadMetadataContext | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
/** Episode currently playing in an EXTERNAL player session. */
readonly activeEpisodeId = input<number | null>(null);
@@ -187,9 +193,7 @@ export class SeasonContainerComponent implements OnInit {
return;
}
this.lastAutoSelectKey = key;
this.selectedSeason.set(
untracked(() => this.resolveAutoSeason())
);
this.selectedSeason.set(untracked(() => this.resolveAutoSeason()));
});
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
@@ -364,6 +368,7 @@ export class SeasonContainerComponent implements OnInit {
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.downloadsEnabled()) return;
if (isStalkerEpisode(episode)) {
this.episodeDownloadRequested.emit(episode);
@@ -384,12 +389,13 @@ export class SeasonContainerComponent implements OnInit {
seriesTitle: this.seriesTitle(),
fallbackSeasonKey: this.selectedSeason(),
posterUrl: this.getEpisodeInfo(episode)?.movie_image,
metadataContext: this.downloadMetadataContext() ?? undefined,
})
);
}
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
@@ -402,7 +408,7 @@ export class SeasonContainerComponent implements OnInit {
}
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
@@ -415,7 +421,7 @@ export class SeasonContainerComponent implements OnInit {
}
isEpisodePaused(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}