mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat(portals): mark a whole series as watched in one click (#1451)
* feat(portals): mark a whole series as watched in one click Adds a series-level watched toggle to the season header's new overflow menu on both Xtream and Stalker series detail pages (issue #1442 v2, building on the season-level toggle from #1447). - Shared: buildSeriesWatchToggleRequest flattens every loaded season with the season builder's mark/unmark semantics; the direction is always the one the label advertised, never re-inferred at persist time. Watch-toggle state math for both scopes moves into the new component-provided SeasonWatchPresenter (the container component sat at the max-lines cap). - Xtream: the series request reuses SerialDetailsSeasonWatchService through a scope-parameterized handle(), the same stillCurrent ownership guard, and the XtreamStore.loadAllPositions badge refresh. - Stalker: the season handler's core is extracted into runWatchToggleBatch (feedback keys per scope). Lazy Ministra VOD hydrates unloaded seasons sequentially first (zero writes on a failed fetch, silent abort on navigation), re-runs the position reconcile synchronously so newly hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction; an all-watched outcome reports an honest count-0 snackbar. - Container: new hasUnloadedSeasons input blocks the fully-watched verdict and the count label while lazy seasons are unloaded, and the empty mark request contract lets the host hydrate-then-rebuild. Six new XTREAM i18n keys, synced to all 18 locales via the i18n-fill workflow. No new IPC: the existing playback-position batch channels are season-agnostic. Refs #1442 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): treat an empty Stalker season answer as loaded, not pending A lazy season the portal ANSWERS for with zero episodes was still counted as unloaded (episodes.length === 0 heuristic): the series label stayed countless forever and every series toggle re-fetched the empty season, while a glitch-empty answer could silently skip a season and still report success as if nothing remained. VodSeriesSeasonVm gains an episodesLoaded flag set by every successful episode fetch — including an empty one — and the series toggle's pending predicate, hydration re-check, and hasUnloadedVodSeasons now key on it. A loaded-and-empty season unblocks the count label and the fully-watched verdict instead of re-fetching; a fresh detail mount still re-fetches, so a one-off glitch self-corrects next session. Addresses the Greptile P1 on PR #1451. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): harden lazy season hydration against malformed and racing loads Two review findings on the series watched toggle: - fetchVodSeriesEpisodes now trusts an empty answer only when the envelope actually carried a well-formed array; a malformed envelope or an answer whose rows contain no recognizable episode rejects, so the load fails instead of the season being recorded loaded-and-empty and silently skipped by the series batch. - loadEpisodesForSeason is single-flight per season: a tab click, the spillover prefetch, the quick-start recursion, and the series-toggle hydration join one in-flight request instead of duplicating portal traffic — previously a second request's failure could abort a series toggle whose original request succeeded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fc9380bff
commit
0a2fe263db
48 files changed
+2340
-261
No files matched your search
@@ -18,6 +18,8 @@ export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
export * from './lib/season-container/season-watch-presenter';
|
||||
export * from './lib/season-container/season-watch-toggle.util';
|
||||
export * from './lib/vod-sources/vod-source-copy-row.component';
|
||||
export * from './lib/vod-sources/vod-source-filtering.util';
|
||||
export * from './lib/vod-sources/vod-source-row.component';
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
[batchRunning]="downloadPresenter.batchRunning()"
|
||||
[seasonDisabled]="downloadPresenter.seasonDisabled()"
|
||||
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
|
||||
[watchToggleVisible]="seasonWatchToggleVisible()"
|
||||
[watchToggleVisible]="watchPresenter.seasonWatchToggleVisible()"
|
||||
[watchBatchRunning]="seasonWatchBatchRunning()"
|
||||
[seasonFullyWatched]="selectedSeasonFullyWatched()"
|
||||
[watchEligibleCount]="seasonWatchEligibleCount()"
|
||||
[seasonFullyWatched]="watchPresenter.selectedSeasonFullyWatched()"
|
||||
[watchEligibleCount]="watchPresenter.seasonWatchEligibleCount()"
|
||||
[seriesMenuVisible]="watchPresenter.seriesMenuVisible()"
|
||||
[seriesActionDisabled]="watchPresenter.seriesActionDisabled()"
|
||||
[seriesFullyWatched]="watchPresenter.seriesFullyWatched()"
|
||||
[seriesEligibleCount]="watchPresenter.seriesWatchEligibleCount()"
|
||||
[seriesCountKnown]="watchPresenter.seriesCountKnown()"
|
||||
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
|
||||
[viewMode]="viewMode()"
|
||||
(downloadSeason)="downloadPresenter.enqueueSeason()"
|
||||
(toggleSeasonWatched)="toggleSeasonWatched()"
|
||||
(toggleSeasonWatched)="watchPresenter.toggleSeasonWatched()"
|
||||
(toggleSeriesWatched)="watchPresenter.toggleSeriesWatched()"
|
||||
(viewModeChange)="setViewMode($event)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -42,28 +42,23 @@ import {
|
||||
SeasonHeaderComponent,
|
||||
} from './season-header.component';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
import { SeasonWatchPresenter } from './season-watch-presenter';
|
||||
import {
|
||||
type SeasonContainerPlaybackToggleRequest,
|
||||
type SeasonContainerSeasonPlaybackToggleRequest,
|
||||
buildSeasonWatchToggleRequest,
|
||||
type SeasonContainerSeriesPlaybackToggleRequest,
|
||||
buildWatchedEpisodePosition,
|
||||
listMarkableEpisodes,
|
||||
resolveEpisodeInfo,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
export type {
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerSeasonPlaybackToggleRequest,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [SeasonDownloadPresenter],
|
||||
providers: [SeasonDownloadPresenter, SeasonWatchPresenter],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
@@ -81,6 +76,7 @@ export class SeasonContainerComponent implements OnInit {
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private lastEmittedSeason: string | undefined;
|
||||
readonly downloadPresenter = inject(SeasonDownloadPresenter);
|
||||
readonly watchPresenter = inject(SeasonWatchPresenter);
|
||||
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly seriesId = input.required<number>();
|
||||
@@ -101,12 +97,20 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
/** True while a host is persisting a season-level watched toggle. */
|
||||
readonly seasonWatchBatchRunning = input(false);
|
||||
/**
|
||||
* True while some seasons' episode lists are not loaded yet (Stalker
|
||||
* lazy-VOD): blocks the series-wide "fully watched" verdict and hides the
|
||||
* count from the series action label.
|
||||
*/
|
||||
readonly hasUnloadedSeasons = input(false);
|
||||
|
||||
readonly episodeClicked = output<XtreamSerieEpisode>();
|
||||
readonly playbackToggleRequested =
|
||||
output<SeasonContainerPlaybackToggleRequest>();
|
||||
readonly seasonPlaybackToggleRequested =
|
||||
output<SeasonContainerSeasonPlaybackToggleRequest>();
|
||||
readonly seriesPlaybackToggleRequested =
|
||||
output<SeasonContainerSeriesPlaybackToggleRequest>();
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
|
||||
@@ -152,51 +156,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
});
|
||||
|
||||
readonly selectedSeasonUnwatchedCount = computed(
|
||||
() =>
|
||||
this.selectedSeasonEpisodes().filter(
|
||||
(episode) => !this.isEpisodeWatched(episode)
|
||||
).length
|
||||
);
|
||||
|
||||
readonly selectedSeasonFullyWatched = computed(
|
||||
() =>
|
||||
this.selectedSeasonEpisodes().length > 0 &&
|
||||
this.selectedSeasonUnwatchedCount() === 0
|
||||
);
|
||||
|
||||
/**
|
||||
* The episode currently playing (inline or externally) or launching is
|
||||
* never bulk-marked: the player persists its live position and would
|
||||
* immediately overwrite the full-progress row.
|
||||
*/
|
||||
private readonly seasonWatchExcludedIds = computed(() => {
|
||||
const ids = [
|
||||
this.playingEpisodeId(),
|
||||
this.activeEpisodeId(),
|
||||
this.openingEpisodeId(),
|
||||
].filter((id): id is number => id !== null);
|
||||
return new Set(ids);
|
||||
});
|
||||
|
||||
/** Count shown in the season toggle label: episodes the action touches. */
|
||||
readonly seasonWatchEligibleCount = computed(() =>
|
||||
this.selectedSeasonFullyWatched()
|
||||
? this.selectedSeasonEpisodes().length
|
||||
: listMarkableEpisodes(
|
||||
this.selectedSeasonEpisodes(),
|
||||
(episode) => this.isEpisodeWatched(episode),
|
||||
this.seasonWatchExcludedIds()
|
||||
).length
|
||||
);
|
||||
|
||||
readonly seasonWatchToggleVisible = computed(
|
||||
() =>
|
||||
this.sortedSeasonKeys().length > 0 &&
|
||||
this.selectedSeasonEpisodes().length > 0 &&
|
||||
!!this.playlistId()
|
||||
);
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
@@ -239,6 +198,24 @@ export class SeasonContainerComponent implements OnInit {
|
||||
selectedSeason: this.selectedSeason,
|
||||
});
|
||||
|
||||
this.watchPresenter.connect({
|
||||
seasons: this.seasons,
|
||||
selectedSeason: this.selectedSeason,
|
||||
selectedSeasonEpisodes: this.selectedSeasonEpisodes,
|
||||
seriesId: this.seriesId,
|
||||
playlistId: this.playlistId,
|
||||
hasUnloadedSeasons: this.hasUnloadedSeasons,
|
||||
batchRunning: this.seasonWatchBatchRunning,
|
||||
playingEpisodeId: this.playingEpisodeId,
|
||||
activeEpisodeId: this.activeEpisodeId,
|
||||
openingEpisodeId: this.openingEpisodeId,
|
||||
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
|
||||
emitSeasonToggle: (request) =>
|
||||
this.seasonPlaybackToggleRequested.emit(request),
|
||||
emitSeriesToggle: (request) =>
|
||||
this.seriesPlaybackToggleRequested.emit(request),
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const key = this.autoSelectKey();
|
||||
if (key === this.lastAutoSelectKey) {
|
||||
@@ -359,29 +336,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
toggleSeasonWatched() {
|
||||
const seasonKey = this.selectedSeason();
|
||||
if (!seasonKey || !this.playlistId()) {
|
||||
this.logger.warn('Cannot toggle season watched: no season/playlist');
|
||||
return;
|
||||
}
|
||||
if (this.seasonWatchBatchRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: this.selectedSeasonEpisodes(),
|
||||
seasonKey,
|
||||
seriesId: this.seriesId(),
|
||||
playlistId: this.playlistId(),
|
||||
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
|
||||
excludedEpisodeIds: this.seasonWatchExcludedIds(),
|
||||
});
|
||||
if (request) {
|
||||
this.seasonPlaybackToggleRequested.emit(request);
|
||||
}
|
||||
}
|
||||
|
||||
getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { signal } 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 {
|
||||
type InterpolatableTranslationObject,
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { of } from 'rxjs';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
import { type SeasonContainerSeriesPlaybackToggleRequest } from './season-watch-toggle.util';
|
||||
|
||||
// Series-scope coverage for the season-header ⋮ menu. The season-scope
|
||||
// toggle cases live in season-container.component.spec.ts, which sits at
|
||||
// the max-lines cap.
|
||||
|
||||
const EN_TRANSLATIONS = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(process.cwd(), 'apps/web/src/assets/i18n/en.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as InterpolatableTranslationObject;
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 0,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
const watchedPosition = (contentXtreamId: number) => ({
|
||||
contentXtreamId,
|
||||
contentType: 'episode' as const,
|
||||
seriesXtreamId: 20,
|
||||
positionSeconds: 100,
|
||||
durationSeconds: 100,
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
|
||||
describe('SeasonContainerComponent series watched toggle', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
let emitted: SeasonContainerSeriesPlaybackToggleRequest[];
|
||||
|
||||
const twoSeasons = () => ({
|
||||
'1': [
|
||||
createEpisode({ id: '101', episode_num: 1 }),
|
||||
createEpisode({ id: '102', episode_num: 2 }),
|
||||
],
|
||||
'2': [createEpisode({ id: '201', episode_num: 1 })],
|
||||
});
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
playlistId = 'playlist-1'
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', playlistId);
|
||||
};
|
||||
|
||||
const menuTrigger = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="series-watch-menu"]'
|
||||
);
|
||||
|
||||
const openSeriesMenuItem = (): HTMLButtonElement => {
|
||||
const trigger = menuTrigger();
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger?.click();
|
||||
fixture.detectChanges();
|
||||
const item = document.querySelector(
|
||||
'[data-test-id="toggle-series-watched"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(item).not.toBeNull();
|
||||
return item as HTMLButtonElement;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
XTREAM: EN_TRANSLATIONS['XTREAM'],
|
||||
} as InterpolatableTranslationObject);
|
||||
translate.setDefaultLang('en');
|
||||
translate.use('en');
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
emitted = [];
|
||||
component.seriesPlaybackToggleRequested.subscribe((request) =>
|
||||
emitted.push(request)
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the menu without a playlist id or without seasons', () => {
|
||||
setRequiredInputs(twoSeasons(), '');
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).toBeNull();
|
||||
|
||||
setRequiredInputs({});
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).toBeNull();
|
||||
|
||||
// Loaded-but-empty seasons only count with the unloaded marker.
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('hasUnloadedSeasons', true);
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('marks every unwatched episode across seasons, excluding the playing one', () => {
|
||||
setRequiredInputs(twoSeasons());
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([[101, watchedPosition(101)]])
|
||||
);
|
||||
fixture.componentRef.setInput('playingEpisodeId', 102);
|
||||
fixture.detectChanges();
|
||||
|
||||
const item = openSeriesMenuItem();
|
||||
// 3 episodes − 1 watched − 1 playing = 1 markable.
|
||||
expect(item.textContent).toContain('Mark series as watched (1)');
|
||||
item.click();
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 201,
|
||||
nextPosition: expect.objectContaining({
|
||||
contentXtreamId: 201,
|
||||
seasonNumber: 2,
|
||||
seriesXtreamId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flips to unwatch-all when every season is watched and clears the playing episode too', () => {
|
||||
setRequiredInputs(twoSeasons());
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[101, watchedPosition(101)],
|
||||
[102, watchedPosition(102)],
|
||||
[201, watchedPosition(201)],
|
||||
])
|
||||
);
|
||||
fixture.componentRef.setInput('playingEpisodeId', 102);
|
||||
fixture.detectChanges();
|
||||
|
||||
const item = openSeriesMenuItem();
|
||||
expect(item.textContent).toContain('Mark series as unwatched');
|
||||
item.click();
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
markWatched: false,
|
||||
requests: [
|
||||
{ contentXtreamId: 101, nextPosition: null },
|
||||
{ contentXtreamId: 102, nextPosition: null },
|
||||
{ contentXtreamId: 201, nextPosition: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the countless label and emits an empty mark request when unloaded seasons hide the total', () => {
|
||||
setRequiredInputs({ '1': [createEpisode({ id: '101' })] });
|
||||
fixture.componentRef.setInput('hasUnloadedSeasons', true);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([[101, watchedPosition(101)]])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const item = openSeriesMenuItem();
|
||||
expect(item.textContent).toContain('Mark series as watched');
|
||||
expect(item.textContent).not.toContain('(');
|
||||
item.click();
|
||||
|
||||
// Every loaded episode is watched — the host hydrates and rebuilds.
|
||||
expect(emitted).toEqual([{ markWatched: true, requests: [] }]);
|
||||
});
|
||||
|
||||
it('disables the action while a batch runs or nothing is actionable', () => {
|
||||
setRequiredInputs(twoSeasons());
|
||||
fixture.componentRef.setInput('seasonWatchBatchRunning', true);
|
||||
fixture.detectChanges();
|
||||
expect(openSeriesMenuItem().disabled).toBe(true);
|
||||
|
||||
// Single unwatched episode which is currently playing: nothing to
|
||||
// mark, series not fully watched, nothing unloaded.
|
||||
fixture.componentRef.setInput('seasonWatchBatchRunning', false);
|
||||
setRequiredInputs({ '1': [createEpisode({ id: '101' })] });
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
fixture.detectChanges();
|
||||
expect(openSeriesMenuItem().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@
|
||||
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
@if (downloadVisible() || watchToggleVisible() || viewToggleVisible()) {
|
||||
@if (
|
||||
downloadVisible() ||
|
||||
watchToggleVisible() ||
|
||||
viewToggleVisible() ||
|
||||
seriesMenuVisible()
|
||||
) {
|
||||
<div class="section-header__actions">
|
||||
@if (watchToggleVisible()) {
|
||||
<button
|
||||
@@ -104,6 +109,47 @@
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
@if (seriesMenuVisible()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="series-watch-menu-trigger"
|
||||
data-test-id="series-watch-menu"
|
||||
[matMenuTriggerFor]="seriesWatchMenu"
|
||||
[attr.aria-label]="'XTREAM.SERIES_WATCH_MENU' | translate"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #seriesWatchMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
class="series-watch-menu-item"
|
||||
data-test-id="toggle-series-watched"
|
||||
[disabled]="seriesActionDisabled()"
|
||||
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
|
||||
(click)="toggleSeriesWatched.emit()"
|
||||
>
|
||||
@if (watchBatchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
seriesFullyWatched()
|
||||
? 'remove_done'
|
||||
: 'done_all'
|
||||
}}</mat-icon>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
seriesActionLabelKey()
|
||||
| translate
|
||||
: { count: seriesEligibleCount() }
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -94,6 +94,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.series-watch-menu-trigger {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.series-watch-menu-item mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -22,6 +23,7 @@ export type EpisodeViewMode = 'grid' | 'list';
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
@@ -36,10 +38,26 @@ export class SeasonHeaderComponent {
|
||||
readonly watchBatchRunning = input.required<boolean>();
|
||||
readonly seasonFullyWatched = input.required<boolean>();
|
||||
readonly watchEligibleCount = input.required<number>();
|
||||
readonly seriesMenuVisible = input.required<boolean>();
|
||||
readonly seriesActionDisabled = input.required<boolean>();
|
||||
readonly seriesFullyWatched = input.required<boolean>();
|
||||
readonly seriesEligibleCount = input.required<number>();
|
||||
/** False while unloaded seasons make the series count a guess. */
|
||||
readonly seriesCountKnown = input.required<boolean>();
|
||||
readonly viewToggleVisible = input.required<boolean>();
|
||||
readonly viewMode = input.required<EpisodeViewMode>();
|
||||
|
||||
readonly downloadSeason = output<void>();
|
||||
readonly toggleSeasonWatched = output<void>();
|
||||
readonly toggleSeriesWatched = output<void>();
|
||||
readonly viewModeChange = output<EpisodeViewMode>();
|
||||
|
||||
seriesActionLabelKey(): string {
|
||||
if (this.seriesFullyWatched()) {
|
||||
return 'XTREAM.MARK_SERIES_UNWATCHED';
|
||||
}
|
||||
return this.seriesCountKnown()
|
||||
? 'XTREAM.MARK_SERIES_WATCHED'
|
||||
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { Injectable, type Signal, computed, signal } from '@angular/core';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type SeasonContainerSeasonPlaybackToggleRequest,
|
||||
type SeasonContainerSeriesPlaybackToggleRequest,
|
||||
buildSeasonWatchToggleRequest,
|
||||
buildSeriesWatchToggleRequest,
|
||||
listMarkableEpisodes,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
export interface SeasonWatchPresenterSources {
|
||||
readonly seasons: Signal<Record<string, XtreamSerieEpisode[]>>;
|
||||
readonly selectedSeason: Signal<string | undefined>;
|
||||
readonly selectedSeasonEpisodes: Signal<readonly XtreamSerieEpisode[]>;
|
||||
readonly seriesId: Signal<number>;
|
||||
readonly playlistId: Signal<string>;
|
||||
/**
|
||||
* True while some seasons exist whose episode lists are not loaded yet
|
||||
* (Stalker lazy-VOD). Blocks the "fully watched" verdict and switches the
|
||||
* series label to its countless variant.
|
||||
*/
|
||||
readonly hasUnloadedSeasons: Signal<boolean>;
|
||||
readonly batchRunning: Signal<boolean>;
|
||||
readonly playingEpisodeId: Signal<number | null>;
|
||||
readonly activeEpisodeId: Signal<number | null>;
|
||||
readonly openingEpisodeId: Signal<number | null>;
|
||||
readonly isEpisodeWatched: (episode: XtreamSerieEpisode) => boolean;
|
||||
readonly emitSeasonToggle: (
|
||||
request: SeasonContainerSeasonPlaybackToggleRequest
|
||||
) => void;
|
||||
readonly emitSeriesToggle: (
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watched-toggle state for the season header, extracted from
|
||||
* `SeasonContainerComponent` (which sits at the max-lines cap): the
|
||||
* season-scoped bulk toggle plus the series-scoped menu action.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeasonWatchPresenter {
|
||||
private readonly logger = createLogger('SeasonWatchPresenter');
|
||||
private readonly sources = signal<SeasonWatchPresenterSources | null>(
|
||||
null
|
||||
);
|
||||
|
||||
connect(sources: SeasonWatchPresenterSources): void {
|
||||
this.sources.set(sources);
|
||||
}
|
||||
|
||||
readonly selectedSeasonUnwatchedCount = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return 0;
|
||||
}
|
||||
return sources
|
||||
.selectedSeasonEpisodes()
|
||||
.filter((episode) => !sources.isEpisodeWatched(episode)).length;
|
||||
});
|
||||
|
||||
readonly selectedSeasonFullyWatched = computed(() => {
|
||||
const sources = this.sources();
|
||||
return (
|
||||
!!sources &&
|
||||
sources.selectedSeasonEpisodes().length > 0 &&
|
||||
this.selectedSeasonUnwatchedCount() === 0
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The episode currently playing (inline or externally) or launching is
|
||||
* never bulk-marked: the player persists its live position and would
|
||||
* immediately overwrite the full-progress row.
|
||||
*/
|
||||
private readonly watchExcludedIds = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return new Set<number>();
|
||||
}
|
||||
const ids = [
|
||||
sources.playingEpisodeId(),
|
||||
sources.activeEpisodeId(),
|
||||
sources.openingEpisodeId(),
|
||||
].filter((id): id is number => id !== null);
|
||||
return new Set(ids);
|
||||
});
|
||||
|
||||
/** Count shown in the season toggle label: episodes the action touches. */
|
||||
readonly seasonWatchEligibleCount = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return 0;
|
||||
}
|
||||
return this.selectedSeasonFullyWatched()
|
||||
? sources.selectedSeasonEpisodes().length
|
||||
: listMarkableEpisodes(
|
||||
sources.selectedSeasonEpisodes(),
|
||||
sources.isEpisodeWatched,
|
||||
this.watchExcludedIds()
|
||||
).length;
|
||||
});
|
||||
|
||||
readonly seasonWatchToggleVisible = computed(() => {
|
||||
const sources = this.sources();
|
||||
return (
|
||||
!!sources &&
|
||||
Object.keys(sources.seasons()).length > 0 &&
|
||||
sources.selectedSeasonEpisodes().length > 0 &&
|
||||
!!sources.playlistId()
|
||||
);
|
||||
});
|
||||
|
||||
toggleSeasonWatched(): void {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return;
|
||||
}
|
||||
const seasonKey = sources.selectedSeason();
|
||||
const playlistId = sources.playlistId();
|
||||
if (!seasonKey || !playlistId) {
|
||||
this.logger.warn(
|
||||
'Cannot toggle season watched: no season/playlist'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sources.batchRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: sources.selectedSeasonEpisodes(),
|
||||
seasonKey,
|
||||
seriesId: sources.seriesId(),
|
||||
playlistId,
|
||||
isEpisodeWatched: sources.isEpisodeWatched,
|
||||
excludedEpisodeIds: this.watchExcludedIds(),
|
||||
});
|
||||
if (request) {
|
||||
sources.emitSeasonToggle(request);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly loadedEpisodes = computed(() => {
|
||||
const sources = this.sources();
|
||||
const episodes: XtreamSerieEpisode[] = [];
|
||||
if (!sources) {
|
||||
return episodes;
|
||||
}
|
||||
for (const seasonEpisodes of Object.values(sources.seasons())) {
|
||||
episodes.push(...(seasonEpisodes ?? []));
|
||||
}
|
||||
return episodes;
|
||||
});
|
||||
|
||||
/** False while unloaded seasons make any series-wide count a guess. */
|
||||
readonly seriesCountKnown = computed(
|
||||
() => !(this.sources()?.hasUnloadedSeasons() ?? false)
|
||||
);
|
||||
|
||||
/**
|
||||
* Only a series whose every season is loaded and watched flips the action
|
||||
* to unwatch-all; unloaded seasons keep the direction at "mark".
|
||||
*/
|
||||
readonly seriesFullyWatched = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources || sources.hasUnloadedSeasons()) {
|
||||
return false;
|
||||
}
|
||||
const episodes = this.loadedEpisodes();
|
||||
return (
|
||||
episodes.length > 0 &&
|
||||
episodes.every((episode) => sources.isEpisodeWatched(episode))
|
||||
);
|
||||
});
|
||||
|
||||
/** Count shown in the series action label: episodes the action touches. */
|
||||
readonly seriesWatchEligibleCount = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return 0;
|
||||
}
|
||||
return this.seriesFullyWatched()
|
||||
? this.loadedEpisodes().length
|
||||
: listMarkableEpisodes(
|
||||
this.loadedEpisodes(),
|
||||
sources.isEpisodeWatched,
|
||||
this.watchExcludedIds()
|
||||
).length;
|
||||
});
|
||||
|
||||
readonly seriesMenuVisible = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (
|
||||
!sources ||
|
||||
!sources.playlistId() ||
|
||||
Object.keys(sources.seasons()).length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.loadedEpisodes().length > 0 || sources.hasUnloadedSeasons()
|
||||
);
|
||||
});
|
||||
|
||||
readonly seriesActionDisabled = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
sources.batchRunning() ||
|
||||
!(
|
||||
this.seriesFullyWatched() ||
|
||||
this.seriesWatchEligibleCount() > 0 ||
|
||||
sources.hasUnloadedSeasons()
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
toggleSeriesWatched(): void {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return;
|
||||
}
|
||||
const playlistId = sources.playlistId();
|
||||
if (!playlistId) {
|
||||
this.logger.warn('Cannot toggle series watched: no playlist');
|
||||
return;
|
||||
}
|
||||
if (sources.batchRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The direction the label advertised, never re-inferred at persist
|
||||
// time: with every loaded episode watched but seasons still unloaded
|
||||
// the label says "mark", while inference over loaded data would flip
|
||||
// to an unwatch.
|
||||
const markWatched = !this.seriesFullyWatched();
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: sources.seasons(),
|
||||
seriesId: sources.seriesId(),
|
||||
playlistId,
|
||||
isEpisodeWatched: sources.isEpisodeWatched,
|
||||
excludedEpisodeIds: this.watchExcludedIds(),
|
||||
markWatched,
|
||||
});
|
||||
if (request) {
|
||||
sources.emitSeriesToggle(request);
|
||||
return;
|
||||
}
|
||||
// No loaded target but unloaded seasons remain: emit an empty mark
|
||||
// request so the host can hydrate the missing seasons and rebuild.
|
||||
if (markWatched && sources.hasUnloadedSeasons()) {
|
||||
sources.emitSeriesToggle({ markWatched: true, requests: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildSeasonWatchToggleRequest,
|
||||
buildSeriesWatchToggleRequest,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
// The Stalker-style '45 min' format parses to 2700 seconds.
|
||||
const PARSED_DURATION = 2700;
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 0,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
const watchedIds = (ids: number[]) => {
|
||||
const set = new Set(ids);
|
||||
return (episode: XtreamSerieEpisode) => set.has(Number(episode.id));
|
||||
};
|
||||
|
||||
describe('buildSeriesWatchToggleRequest', () => {
|
||||
const twoSeasons = (): Record<string, XtreamSerieEpisode[]> => ({
|
||||
'1': [
|
||||
createEpisode({ id: '101', episode_num: 1 }),
|
||||
createEpisode({ id: '102', episode_num: 2 }),
|
||||
],
|
||||
'2': [
|
||||
createEpisode({ id: '201', episode_num: 1 }),
|
||||
createEpisode({ id: '202', episode_num: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
it('marks unwatched episodes across every season with per-season numbers', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101]),
|
||||
});
|
||||
|
||||
expect(request?.markWatched).toBe(true);
|
||||
expect(request?.requests.map((item) => item.contentXtreamId)).toEqual([
|
||||
102, 201, 202,
|
||||
]);
|
||||
// Episodes without their own season fall back to the KEY of the
|
||||
// season they came from, not one shared fallback.
|
||||
expect(
|
||||
request?.requests.map((item) => item.nextPosition?.seasonNumber)
|
||||
).toEqual([1, 2, 2]);
|
||||
expect(request?.requests[0].nextPosition).toEqual(
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 102,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 20,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: PARSED_DURATION,
|
||||
durationSeconds: PARSED_DURATION,
|
||||
playlistId: 'playlist-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips excluded episodes when marking', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
excludedEpisodeIds: new Set([201]),
|
||||
});
|
||||
|
||||
expect(request?.requests.map((item) => item.contentXtreamId)).toEqual([
|
||||
101, 102, 202,
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears every episode of every season on unwatch, exclusions ignored', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101, 102, 201, 202]),
|
||||
excludedEpisodeIds: new Set([101]),
|
||||
});
|
||||
|
||||
expect(request?.markWatched).toBe(false);
|
||||
expect(request?.requests).toEqual([
|
||||
{ contentXtreamId: 101, nextPosition: null },
|
||||
{ contentXtreamId: 102, nextPosition: null },
|
||||
{ contentXtreamId: 201, nextPosition: null },
|
||||
{ contentXtreamId: 202, nextPosition: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when there is nothing to touch', () => {
|
||||
expect(
|
||||
buildSeriesWatchToggleRequest({
|
||||
seasons: {},
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildSeriesWatchToggleRequest({
|
||||
seasons: { '1': [] },
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('honors a forced mark direction over inference', () => {
|
||||
// Everything watched: inference would unwatch, the forced direction
|
||||
// (what the label advertised) yields no targets instead.
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101, 102, 201, 202]),
|
||||
markWatched: true,
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
|
||||
it('honors a forced unwatch direction and still clears all episodes', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101]),
|
||||
markWatched: false,
|
||||
});
|
||||
|
||||
expect(request?.markWatched).toBe(false);
|
||||
expect(request?.requests).toHaveLength(4);
|
||||
expect(
|
||||
request?.requests.every((item) => item.nextPosition === null)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSeasonWatchToggleRequest', () => {
|
||||
it('returns null for an empty season', () => {
|
||||
expect(
|
||||
buildSeasonWatchToggleRequest({
|
||||
episodes: [],
|
||||
seasonKey: '1',
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to a one-second duration when nothing parses', () => {
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: [createEpisode({ id: '101', info: undefined })],
|
||||
seasonKey: '3',
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
});
|
||||
|
||||
expect(request?.requests[0].nextPosition).toEqual(
|
||||
expect.objectContaining({
|
||||
positionSeconds: 1,
|
||||
durationSeconds: 1,
|
||||
seasonNumber: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an episode-level season over the fallback key', () => {
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: [createEpisode({ id: '101', season: 5 })],
|
||||
seasonKey: '3',
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
});
|
||||
|
||||
expect(request?.requests[0].nextPosition?.seasonNumber).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,16 @@ export interface SeasonContainerPlaybackToggleRequest {
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
}
|
||||
|
||||
export interface SeasonContainerSeasonPlaybackToggleRequest {
|
||||
seasonKey: string;
|
||||
export interface SeasonContainerSeriesPlaybackToggleRequest {
|
||||
markWatched: boolean;
|
||||
requests: SeasonContainerPlaybackToggleRequest[];
|
||||
}
|
||||
|
||||
export interface SeasonContainerSeasonPlaybackToggleRequest
|
||||
extends SeasonContainerSeriesPlaybackToggleRequest {
|
||||
seasonKey: string;
|
||||
}
|
||||
|
||||
export function resolveEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
@@ -112,3 +116,64 @@ export function buildSeasonWatchToggleRequest(options: {
|
||||
|
||||
return { seasonKey: options.seasonKey, markWatched, requests };
|
||||
}
|
||||
|
||||
/**
|
||||
* Series-level bulk toggle request across every LOADED season, with the same
|
||||
* mark/unmark semantics as the season builder (marking touches only unwatched
|
||||
* episodes minus the exclusions; unmarking clears every episode, exclusions
|
||||
* ignored). Each season's key is that season's duration fallback, so
|
||||
* cross-season position rows keep their own season numbers.
|
||||
*
|
||||
* `markWatched` forces the direction the UI advertised. Without it the
|
||||
* direction is inferred from the loaded data — wrong for a lazily loaded
|
||||
* series whose loaded episodes are all watched while unloaded seasons remain:
|
||||
* the label says "mark", inference would say "unmark". Hosts that rebuild a
|
||||
* request after hydrating such seasons must pass the direction captured at
|
||||
* click time.
|
||||
*/
|
||||
export function buildSeriesWatchToggleRequest(options: {
|
||||
seasons: Record<string, readonly XtreamSerieEpisode[]>;
|
||||
seriesId: number;
|
||||
playlistId: string;
|
||||
isEpisodeWatched: (episode: XtreamSerieEpisode) => boolean;
|
||||
excludedEpisodeIds?: ReadonlySet<number>;
|
||||
markWatched?: boolean;
|
||||
}): SeasonContainerSeriesPlaybackToggleRequest | null {
|
||||
const seasonEntries = Object.entries(options.seasons);
|
||||
const markWatched =
|
||||
options.markWatched ??
|
||||
seasonEntries.some(([, episodes]) =>
|
||||
(episodes ?? []).some(
|
||||
(episode) => !options.isEpisodeWatched(episode)
|
||||
)
|
||||
);
|
||||
|
||||
const requests: SeasonContainerPlaybackToggleRequest[] = [];
|
||||
for (const [seasonKey, episodes] of seasonEntries) {
|
||||
const targets = markWatched
|
||||
? listMarkableEpisodes(
|
||||
episodes ?? [],
|
||||
options.isEpisodeWatched,
|
||||
options.excludedEpisodeIds
|
||||
)
|
||||
: (episodes ?? []);
|
||||
for (const episode of targets) {
|
||||
requests.push({
|
||||
contentXtreamId: Number(episode.id),
|
||||
nextPosition: markWatched
|
||||
? buildWatchedEpisodePosition({
|
||||
episode,
|
||||
seriesId: options.seriesId,
|
||||
playlistId: options.playlistId,
|
||||
fallbackSeasonKey: seasonKey,
|
||||
})
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { markWatched, requests };
|
||||
}
|
||||
Reference in new issue
Block a user