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:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-16 01:04:12 +02:00
1 parent 7fc9380bff
commit 0a2fe263db
48 files changed
+2340 -261

No files matched your search

+2
View File
@@ -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 };
}