mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user