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

@@ -20,6 +20,13 @@ export interface VodSeriesSeasonVm {
episodes: StalkerVodSeriesEpisode[];
isLoading: boolean;
isExpanded: boolean;
/**
* True once the portal has answered an episode fetch for this season —
* including an EMPTY answer. Distinguishes "loaded and empty per portal"
* from "not fetched yet": `episodes.length === 0` alone conflates them,
* which would keep an empty season permanently counted as unloaded.
*/
episodesLoaded?: boolean;
}
export interface StalkerSeriesSeasonVm {
@@ -246,4 +246,56 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(1);
});
// Only a well-formed empty array is a trusted empty season: the series
// watched toggle marks an answered-empty season as loaded, so a
// malformed envelope collapsing to [] would silently skip that season.
describe('fetchVodSeriesEpisodes strictness', () => {
it('trusts a well-formed empty answer in either envelope shape', async () => {
dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } });
await expect(
store.fetchVodSeriesEpisodes('11', 'season-2')
).resolves.toEqual([]);
dataService.sendIpcEvent.mockResolvedValue({ js: [] });
await expect(
store.fetchVodSeriesEpisodes('11', 'season-2')
).resolves.toEqual([]);
});
it('rejects a malformed envelope instead of reporting an empty season', async () => {
dataService.sendIpcEvent.mockResolvedValue({ js: {} });
await expect(
store.fetchVodSeriesEpisodes('11', 'season-2')
).rejects.toThrow('Malformed Stalker season episodes response');
});
it('rejects an answer whose rows contain no recognizable episode', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: { data: [{ id: 'junk-row', is_season: true }] },
});
await expect(
store.fetchVodSeriesEpisodes('11', 'season-2')
).rejects.toThrow(
'Stalker season answered without recognizable episodes'
);
});
it('returns sorted episodes for a proper answer', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
{ id: 'e2', is_episode: true, series_number: 2 },
{ id: 'e1', is_episode: true, series_number: 1 },
],
},
});
await expect(
store.fetchVodSeriesEpisodes('11', 'season-2')
).resolves.toEqual([
{ id: 'e1', is_episode: true, series_number: 1 },
{ id: 'e2', is_episode: true, series_number: 2 },
]);
});
});
});
@@ -53,7 +53,15 @@ interface StalkerSeriesResponse<T> {
type StalkerSeriesStoreContext = StalkerSeriesFeatureStoreContract;
function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
/**
* Strict variant: null when the envelope carried no array at all, so a
* malformed answer is distinguishable from a well-formed empty list. The
* series watched toggle treats an answered-empty season as loaded, so a
* malformed envelope collapsing to [] would silently skip that season.
*/
function extractSeriesItemsStrict<T>(
response: StalkerSeriesResponse<T>
): T[] | null {
if (Array.isArray(response?.js)) {
return response.js;
}
@@ -62,7 +70,11 @@ function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
return response.js.data;
}
return [];
return null;
}
function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
return extractSeriesItemsStrict(response) ?? [];
}
function toMovieId(value: unknown): string {
@@ -257,7 +269,16 @@ export function withStalkerSeries() {
p: '1',
});
const episodeItems = extractSeriesItems(response);
// Only a well-formed empty array is a trusted empty
// season. A malformed envelope, or rows in which no
// episode is recognizable, rejects so callers treat
// the load as failed instead of loaded-and-empty.
const episodeItems = extractSeriesItemsStrict(response);
if (episodeItems === null) {
throw new Error(
'Malformed Stalker season episodes response'
);
}
if (episodeItems.length === 0) {
return [];
}
@@ -267,6 +288,11 @@ export function withStalkerSeries() {
(item) => item.is_episode === true
)
);
if (episodes.length === 0) {
throw new Error(
'Stalker season answered without recognizable episodes'
);
}
patchState(store, {
vodSeriesEpisodes: episodes,
@@ -210,6 +210,7 @@
isCurrentSeasonLoading(seasonContainer?.selectedSeason())
"
[seasonWatchBatchRunning]="seasonWatchBatchRunning()"
[hasUnloadedSeasons]="hasUnloadedVodSeasons()"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
(playbackToggleRequested)="
@@ -218,6 +219,9 @@
(seasonPlaybackToggleRequested)="
handleSeasonPlaybackToggleRequestedFromUi($event)
"
(seriesPlaybackToggleRequested)="
handleSeriesPlaybackToggleRequestedFromUi($event)
"
#seasonContainer
/>
}
@@ -25,11 +25,11 @@ import { StalkerSeriesViewComponent } from './stalker-series-view.component';
@Component({
selector: 'app-season-container',
standalone: true,
template: '<div data-testid="season-container"></div>',
})
class StubSeasonContainerComponent {
readonly seasons = input<unknown>(null);
readonly hasUnloadedSeasons = input(false);
readonly seriesId = input<number | string | null>(null);
readonly playlistId = input('');
readonly seriesTitle = input<string | undefined>(undefined);
@@ -25,6 +25,8 @@ import {
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
SeasonContainerSeasonPlaybackToggleRequest,
SeasonContainerSeriesPlaybackToggleRequest,
buildSeriesWatchToggleRequest,
} from '@iptvnator/ui/components';
import {
pickSeasonMarkedTitle,
@@ -38,6 +40,7 @@ import {
import { SafePipe } from '@iptvnator/pipes';
import {
isLiveExternalPlayerSession,
isPortalPlaybackWatched,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -107,6 +110,33 @@ interface SeriesPositionContext {
readonly mutationKey: string;
}
interface StalkerWatchToggleFeedback {
readonly marked: string;
readonly unmarked: string;
readonly partialMarked: string;
readonly partialUnmarked: string;
readonly failed: string;
}
// The marked and partial keys are scope-generic on purpose ("{{count}}
// episodes marked as watched", "{{count}} marked · {{failed}} failed");
// only unmark-success and failure name their scope.
const SEASON_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED',
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED',
};
const SERIES_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED',
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED',
};
interface StalkerSeriesPlaybackRequestContext {
readonly generation: number;
readonly usesEmbeddedPlayer: boolean;
@@ -389,41 +419,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
});
effect(() => {
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesXtreamId = this.toSeriesId(item?.id ?? 0);
const rawSeriesPositions = this.rawSeriesPositions();
const episodesBySeason = this.mappedSeasons();
if (!item || !playlistId || seriesXtreamId <= 0) {
if (rawSeriesPositions.length > 0) {
this.rawSeriesPositions.set([]);
}
if (this.episodePlaybackPositions().size > 0) {
this.episodePlaybackPositions.set(new Map());
}
if (this.legacyPositionByTrackingId().size > 0) {
this.legacyPositionByTrackingId.set(new Map());
}
return;
}
const reconciled = reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason,
seriesPositions: rawSeriesPositions,
});
if (
rawSeriesPositions.length === 0 &&
reconciled.positionsByTrackingId.size === 0 &&
untracked(() => this.episodePlaybackPositions().size) > 0
) {
return;
}
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
this.applyReconciledSeriesPositions();
});
effect(() => {
@@ -723,11 +719,37 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
private readonly vodSeasonEpisodeLoads = new Map<
string,
Promise<boolean>
>();
/**
* Loads episodes for a specific VOD season
* Loads episodes for a specific VOD season.
*
* Single-flight per season: a tab click, the spillover prefetch, the
* quick-start recursion, and the series-toggle hydration can all ask for
* the same season — a second concurrent request would duplicate portal
* traffic, and its failure could abort a series toggle whose original
* request succeeded.
*/
/** Resolves true when the portal answered, false when the request failed. */
async loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise<boolean> {
loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise<boolean> {
const key = `${season.video_id}:${season.id}`;
const inFlight = this.vodSeasonEpisodeLoads.get(key);
if (inFlight) {
return inFlight;
}
const load = this.fetchEpisodesForSeason(season).finally(() => {
this.vodSeasonEpisodeLoads.delete(key);
});
this.vodSeasonEpisodeLoads.set(key, load);
return load;
}
private async fetchEpisodesForSeason(
season: VodSeriesSeasonVm
): Promise<boolean> {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
@@ -750,6 +772,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
newSeasons[newIndex] = {
...newSeasons[newIndex],
episodes: episodes,
// Even an EMPTY answer marks the season loaded: the
// portal spoke, so it must stop counting as "unloaded"
// (label/verdict gating and series-toggle hydration).
episodesLoaded: true,
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
@@ -1129,76 +1155,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.seasonWatchBatchRunning.set(true);
try {
// Enqueue every episode synchronously: each mutation chains on
// the previous one's never-rejecting barrier, so the queue
// serializes the writes (incl. per-episode legacy-row cleanup)
// and reloads positions once after the whole chain drains.
const outcomes = await Promise.all(
request.requests.map((item) =>
(item.nextPosition
? this.persistSeriesPosition(
playlistId,
item.nextPosition
)
: this.clearSeriesPosition(
playlistId,
item.contentXtreamId
)
).then(
() => true,
// The scoped watched row was saved and published —
// only the legacy-row cleanup failed. The episode IS
// watched, so it must not count against the batch.
(error: unknown) =>
error instanceof
StalkerSeriesPositionPartialSaveError
)
)
await this.runWatchToggleBatch(
request,
playlistId,
stillCurrent,
SEASON_WATCH_FEEDBACK
);
const failed = outcomes.filter((ok) => !ok).length;
const succeeded = outcomes.length - failed;
if (failed > 0) {
this.logger.error(
`Season watched toggle: ${failed} of ${outcomes.length} episodes failed`
);
}
if (succeeded > 0) {
// Partial successes changed rows too — the catalog badge
// must follow even when the user already moved on. A failed
// refresh must not break the feedback flow below.
await this.catalogFacade
?.refreshPositions(playlistId)
.catch((error: unknown) =>
this.logger.warn(
'Catalog position refresh failed',
error
)
);
}
if (!stillCurrent()) {
return;
}
if (failed === 0) {
this.notifySeasonWatchToggle(
request.markWatched
? 'XTREAM.SEASON_MARKED_WATCHED'
: 'XTREAM.SEASON_MARKED_UNWATCHED',
{ count: succeeded }
);
} else if (succeeded > 0) {
this.notifySeasonWatchToggle(
request.markWatched
? 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL'
: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
{ count: succeeded, failed }
);
} else {
this.notifySeasonWatchToggle(
'XTREAM.SEASON_WATCH_UPDATE_FAILED'
);
}
} finally {
this.seasonWatchBatchRunning.set(false);
}
@@ -1217,6 +1179,280 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
}
/**
* True for a season the portal has never answered for. A season that
* answered with zero episodes is loaded-and-empty, not pending — treating
* it as pending would keep the series label countless forever and make
* every series toggle re-fetch it.
*/
private isSeasonHydrationPending(season: VodSeriesSeasonVm): boolean {
return season.episodes.length === 0 && !season.episodesLoaded;
}
/** Seasons whose episode lists still need a portal request (lazy VOD). */
readonly hasUnloadedVodSeasons = computed(
() =>
this.isVodSeries() &&
this.vodSeriesSeasons().some((season) =>
this.isSeasonHydrationPending(season)
)
);
async handleSeriesPlaybackToggleRequested(
request: SeasonContainerSeriesPlaybackToggleRequest
): Promise<void> {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId || this.seasonWatchBatchRunning()) {
return;
}
const pendingSeasons = this.isVodSeries()
? this.vodSeriesSeasons().filter((season) =>
this.isSeasonHydrationPending(season)
)
: [];
// An empty request is only meaningful when unloaded seasons remain:
// every loaded episode is watched, so the container could not build
// a target list, but hydration below may still surface unwatched
// episodes to mark.
if (request.requests.length === 0 && pendingSeasons.length === 0) {
return;
}
const seriesXtreamId = this.toSeriesId(this.displayItem()?.id ?? 0);
const stillCurrent = () =>
this.stalkerStore.currentPlaylist()?._id === playlistId &&
this.toSeriesId(this.displayItem()?.id ?? 0) === seriesXtreamId;
this.seasonWatchBatchRunning.set(true);
try {
let effective: SeasonContainerSeriesPlaybackToggleRequest | null =
request;
if (pendingSeasons.length > 0) {
const hydrated = await this.hydrateSeasonsForSeriesToggle(
pendingSeasons,
stillCurrent
);
if (hydrated !== 'complete') {
if (hydrated === 'failed' && stillCurrent()) {
this.notifySeasonWatchToggle(
SERIES_WATCH_FEEDBACK.failed
);
}
return;
}
// The reconcile effect only flushes on the next change-
// detection tick; rebuild the maps synchronously so the
// batch below sees the hydrated episodes' scoped and legacy
// rows (see applyReconciledSeriesPositions).
this.applyReconciledSeriesPositions();
effective = buildSeriesWatchToggleRequest({
seasons: this.mappedSeasons(),
seriesId: seriesXtreamId,
playlistId,
isEpisodeWatched: (episode) =>
isPortalPlaybackWatched(
this.episodePlaybackPositions().get(
Number(episode.id)
)
),
excludedEpisodeIds: this.seriesWatchExcludedIds(),
// Keep the direction the user clicked; re-inference over
// the now-complete data could flip a "mark" into an
// unwatch when everything turned out watched.
markWatched: request.markWatched,
});
if (!effective) {
if (stillCurrent()) {
this.notifySeasonWatchToggle(
SERIES_WATCH_FEEDBACK.marked,
{ count: 0 }
);
}
return;
}
}
await this.runWatchToggleBatch(
effective,
playlistId,
stillCurrent,
SERIES_WATCH_FEEDBACK
);
} finally {
this.seasonWatchBatchRunning.set(false);
}
}
handleSeriesPlaybackToggleRequestedFromUi(
request: SeasonContainerSeriesPlaybackToggleRequest
): void {
void this.handleSeriesPlaybackToggleRequested(request).catch(
(error: unknown) => {
this.logger.error(
'Failed to toggle series watched state',
error
);
}
);
}
/**
* Sequential on purpose: loadEpisodesForSeason snapshots the season VM
* array before its writes, so concurrent calls clobber each other's
* loading flags; and one request at a time keeps the portal load bounded.
*/
private async hydrateSeasonsForSeriesToggle(
pendingSeasons: readonly VodSeriesSeasonVm[],
stillCurrent: () => boolean
): Promise<'complete' | 'failed' | 'superseded'> {
for (const season of pendingSeasons) {
// A tab click may have loaded this season meanwhile.
const current = this.vodSeriesSeasons().find(
(candidate) => candidate.id === season.id
);
if (!current || !this.isSeasonHydrationPending(current)) {
continue;
}
const answered = await this.loadEpisodesForSeason(current);
if (!stillCurrent()) {
return 'superseded';
}
if (!answered) {
return 'failed';
}
}
return 'complete';
}
/**
* Mirrors the exclusions the template binds into the season container
* (playingEpisodeId / activeEpisodeId / openingEpisodeId): the episode
* playing or launching is never bulk-marked, because its live position
* ticks would immediately overwrite the full-progress row.
*/
private seriesWatchExcludedIds(): ReadonlySet<number> {
const ids = [
this.openingEpisodeId(),
this.activeEpisodeId(),
this.inlinePlayback()?.contentInfo?.contentXtreamId ?? null,
].filter((id): id is number => id !== null);
return new Set(ids);
}
private async runWatchToggleBatch(
request: SeasonContainerSeriesPlaybackToggleRequest,
playlistId: string,
stillCurrent: () => boolean,
feedback: StalkerWatchToggleFeedback
): Promise<void> {
// Enqueue every episode synchronously: each mutation chains on
// the previous one's never-rejecting barrier, so the queue
// serializes the writes (incl. per-episode legacy-row cleanup)
// and reloads positions once after the whole chain drains.
const outcomes = await Promise.all(
request.requests.map((item) =>
(item.nextPosition
? this.persistSeriesPosition(playlistId, item.nextPosition)
: this.clearSeriesPosition(
playlistId,
item.contentXtreamId
)
).then(
() => true,
// The scoped watched row was saved and published —
// only the legacy-row cleanup failed. The episode IS
// watched, so it must not count against the batch.
(error: unknown) =>
error instanceof StalkerSeriesPositionPartialSaveError
)
)
);
const failed = outcomes.filter((ok) => !ok).length;
const succeeded = outcomes.length - failed;
if (failed > 0) {
this.logger.error(
`Watched toggle: ${failed} of ${outcomes.length} episodes failed`
);
}
if (succeeded > 0) {
// Partial successes changed rows too — the catalog badge
// must follow even when the user already moved on. A failed
// refresh must not break the feedback flow below.
await this.catalogFacade
?.refreshPositions(playlistId)
.catch((error: unknown) =>
this.logger.warn('Catalog position refresh failed', error)
);
}
if (!stillCurrent()) {
return;
}
if (failed === 0) {
this.notifySeasonWatchToggle(
request.markWatched ? feedback.marked : feedback.unmarked,
{ count: succeeded }
);
} else if (succeeded > 0) {
this.notifySeasonWatchToggle(
request.markWatched
? feedback.partialMarked
: feedback.partialUnmarked,
{ count: succeeded, failed }
);
} else {
this.notifySeasonWatchToggle(feedback.failed);
}
}
/**
* Maps the raw series position rows onto the currently mapped episodes
* (scoped rows plus compatible legacy promotions). Runs reactively from
* the constructor effect, and synchronously from the series-level watch
* toggle right after it hydrates lazy seasons — the effect only re-runs
* on the next change-detection tick, and enqueuing the batch against the
* stale maps would miss the hydrated episodes' legacy rows (an unwatch
* would leave rows behind that a later reconcile resurrects as watched).
*/
private applyReconciledSeriesPositions(): void {
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesXtreamId = this.toSeriesId(item?.id ?? 0);
const rawSeriesPositions = this.rawSeriesPositions();
const episodesBySeason = this.mappedSeasons();
if (!item || !playlistId || seriesXtreamId <= 0) {
if (rawSeriesPositions.length > 0) {
this.rawSeriesPositions.set([]);
}
if (this.episodePlaybackPositions().size > 0) {
this.episodePlaybackPositions.set(new Map());
}
if (this.legacyPositionByTrackingId().size > 0) {
this.legacyPositionByTrackingId.set(new Map());
}
return;
}
const reconciled = reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason,
seriesPositions: rawSeriesPositions,
});
if (
rawSeriesPositions.length === 0 &&
reconciled.positionsByTrackingId.size === 0 &&
untracked(() => this.episodePlaybackPositions().size) > 0
) {
return;
}
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
}
private notifySeasonWatchToggle(key: string, params?: object): void {
this.snackBar.open(
this.translateService.instant(key, params),
@@ -10,6 +10,8 @@ import {
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
mapVodSeriesEpisodes,
type StalkerMappedEpisode,
type StalkerVodSource,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
@@ -397,4 +399,306 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
expect(snackBarCalls().length).toBe(callsBefore);
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false);
});
describe('series scope', () => {
function createSecondSeason(
episodes: VodSeriesSeasonVm['episodes'] = []
): VodSeriesSeasonVm {
return {
id: 'season-2',
video_id: String(SERIES_A_ID),
name: 'Season 2',
season_number: '2',
episodes,
isLoading: false,
isExpanded: false,
};
}
// The real mapper is the oracle for the tracking ids a hydrated
// season will produce, so legacy rows can be seeded BEFORE the
// toggle hydrates that season.
const s2ProviderEpisode = () =>
createProviderEpisode('provider-episode-s2', 1);
const s2Mapped = mapVodSeriesEpisodes(
[createSecondSeason([s2ProviderEpisode()])],
{ parentSeriesId: SERIES_A_ID, fallbackPoster: '' }
)['2'][0] as StalkerMappedEpisode;
const S2_SCOPED_ID = Number(s2Mapped.id);
const S2_LEGACY_ID = Number(s2Mapped.legacyTrackingId);
function seriesToggleRequest(ids: number[], markWatched: boolean) {
return {
markWatched,
requests: ids.map((contentXtreamId, index) => ({
contentXtreamId,
nextPosition: markWatched
? createPosition({
contentXtreamId,
episodeNumber: index + 1,
positionSeconds: 100,
})
: null,
})),
};
}
async function startWithLazySecondSeason(): Promise<number> {
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: String(SERIES_A_ID),
name: 'Season 1',
season_number: '1',
},
]);
await settle();
fixture.componentInstance.vodSeriesSeasons.set([
createSeason(SERIES_A_ID, [createProviderEpisode()]),
createSecondSeason(),
]);
await settle();
return Number(
fixture.componentInstance.mappedSeasons()['1'][0].id
);
}
function mockSecondSeasonFetch(): jest.Mock {
const fetch = TestBed.inject(StalkerStore)
.fetchVodSeriesEpisodes as jest.Mock;
fetch.mockResolvedValue([s2ProviderEpisode()]);
return fetch;
}
it('marks every season directly when all seasons are loaded', async () => {
const [firstId, secondId] = await startWithTwoLoadedEpisodes();
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId, secondId], true)
);
expect(repositoryOrder).toEqual([
`save:${firstId}`,
`save:${secondId}`,
]);
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
});
it('hydrates lazy seasons, then marks them with legacy-row cleanup', async () => {
// A pre-scope legacy row for the NOT-yet-hydrated season 2
// episode: the sync reconcile after hydration must surface it so
// the save cleans it up — enqueuing against the effect-fed maps
// would miss it (the effect only flushes on the next CD tick).
repositoryRows = [
createPosition({
contentXtreamId: S2_LEGACY_ID,
seasonNumber: 2,
episodeNumber: 1,
}),
];
const firstId = await startWithLazySecondSeason();
const fetch = mockSecondSeasonFetch();
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId], true)
);
expect(fetch).toHaveBeenCalledWith(
String(SERIES_A_ID),
'season-2'
);
expect(repositoryOrder).toEqual([
`save:${firstId}`,
`save:${S2_SCOPED_ID}`,
`clear:${S2_LEGACY_ID}`,
]);
expect(
repositoryRows.some(
(row) => row.contentXtreamId === S2_LEGACY_ID
)
).toBe(false);
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID);
});
it('treats an empty portal answer as loaded instead of eternally pending', async () => {
// The portal ANSWERS for season 2 but reports zero episodes. The
// season is then loaded-and-empty, not pending: the rest of the
// series still gets marked, the countless label unblocks, and a
// follow-up toggle must not re-fetch the empty season.
const firstId = await startWithLazySecondSeason();
const fetch = TestBed.inject(StalkerStore)
.fetchVodSeriesEpisodes as jest.Mock;
fetch.mockResolvedValue([]);
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId], true)
);
expect(fetch).toHaveBeenCalledTimes(1);
expect(repositoryOrder).toEqual([`save:${firstId}`]);
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
expect(
fixture.componentInstance.hasUnloadedVodSeasons()
).toBe(false);
// Everything loaded is now watched and nothing is pending — an
// empty follow-up mark request is a no-op, not another fetch.
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([], true)
);
expect(fetch).toHaveBeenCalledTimes(1);
expect(repositoryOrder).toEqual([`save:${firstId}`]);
});
it('reuses an in-flight tab-click season load instead of duplicating it', async () => {
const firstId = await startWithLazySecondSeason();
let releaseFetch!: (episodes: unknown[]) => void;
const fetch = TestBed.inject(StalkerStore)
.fetchVodSeriesEpisodes as jest.Mock;
fetch.mockImplementation(
() =>
new Promise((resolve) => {
releaseFetch = resolve;
})
);
// The user opened the season tab; that load is still on the
// wire when the series action starts hydrating — it must join
// the in-flight request, not start a second one whose failure
// could abort the toggle independently.
const season2 = fixture.componentInstance.vodSeriesSeasons()[1];
const tabLoad =
fixture.componentInstance.loadEpisodesForSeason(season2);
const pending =
fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId], true)
);
releaseFetch([s2ProviderEpisode()]);
await Promise.all([tabLoad, pending]);
expect(fetch).toHaveBeenCalledTimes(1);
expect(repositoryOrder).toEqual([
`save:${firstId}`,
`save:${S2_SCOPED_ID}`,
]);
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
});
it('aborts with zero writes when hydrating a season fails', async () => {
const firstId = await startWithLazySecondSeason();
(
TestBed.inject(StalkerStore).fetchVodSeriesEpisodes as jest.Mock
).mockRejectedValue(new Error('portal down'));
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId], true)
);
expect(repositoryOrder).toEqual([]);
expectSeasonToggleSnackbar('XTREAM.SERIES_WATCH_UPDATE_FAILED');
expect(refreshPositions).not.toHaveBeenCalled();
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
});
it('aborts silently when the series changes during hydration', async () => {
const firstId = await startWithLazySecondSeason();
let releaseFetch!: (episodes: unknown[]) => void;
(
TestBed.inject(StalkerStore).fetchVodSeriesEpisodes as jest.Mock
).mockImplementation(
() =>
new Promise((resolve) => {
releaseFetch = resolve;
})
);
const callsBefore = snackBarCalls().length;
const pending =
fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId], true)
);
selectedItem.set(createVodItem(SERIES_B_ID));
releaseFetch([s2ProviderEpisode()]);
await pending;
expect(repositoryOrder).toEqual([]);
expect(snackBarCalls().length).toBe(callsBefore);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
});
it('reports a zero count when hydration reveals nothing to mark', async () => {
// Every episode — loaded and hydrated alike — is already
// watched, but the empty mark request must still be honored
// (the container cannot know before hydration).
const firstId = await startWithLazySecondSeason();
repositoryRows = [
createPosition({
contentXtreamId: firstId,
positionSeconds: 100,
}),
createPosition({
contentXtreamId: S2_SCOPED_ID,
seasonNumber: 2,
positionSeconds: 100,
}),
];
selectedItem.set(createVodItem(SERIES_A_ID));
await settle();
mockSecondSeasonFetch();
const writesBefore = repositoryOrder.length;
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([], true)
);
expect(repositoryOrder.length).toBe(writesBefore);
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
});
it('locks the season toggle while hydration is in flight', async () => {
const firstId = await startWithLazySecondSeason();
let releaseFetch!: (episodes: unknown[]) => void;
(
TestBed.inject(StalkerStore).fetchVodSeriesEpisodes as jest.Mock
).mockImplementation(
() =>
new Promise((resolve) => {
releaseFetch = resolve;
})
);
const pending =
fixture.componentInstance.handleSeriesPlaybackToggleRequested(
seriesToggleRequest([firstId], true)
);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(true);
// A season toggle during hydration must not enqueue anything.
await fixture.componentInstance.handleSeasonPlaybackToggleRequested(
seasonToggleRequest([firstId], true)
);
expect(repositoryOrder).toEqual([]);
releaseFetch([s2ProviderEpisode()]);
await pending;
expect(repositoryOrder).toEqual([
`save:${firstId}`,
`save:${S2_SCOPED_ID}`,
]);
expect(
fixture.componentInstance.seasonWatchBatchRunning()
).toBe(false);
});
});
});
@@ -27,7 +27,7 @@ import {
} from '@iptvnator/shared/interfaces';
import {
SeasonContainerPlaybackToggleRequest,
SeasonContainerSeasonPlaybackToggleRequest,
SeasonContainerSeriesPlaybackToggleRequest,
} from '@iptvnator/ui/components';
import {
getSeriesEpisodeMetadata,
@@ -38,7 +38,10 @@ import {
} from '@iptvnator/ui/playback';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
import {
SerialDetailsSeasonWatchService,
type SerialDetailsWatchScope,
} from './serial-details-season-watch.service';
export type XtreamSerieDetailsView = XtreamSerieDetails & {
readonly series_id: number;
@@ -343,8 +346,9 @@ export class SerialDetailsPlaybackService {
await this.refreshStorePositions(playlistId);
}
async handleSeasonPlaybackToggleRequested(
request: SeasonContainerSeasonPlaybackToggleRequest
async handleWatchToggleRequested(
request: SeasonContainerSeriesPlaybackToggleRequest,
scope: SerialDetailsWatchScope
): Promise<void> {
const playlistId = this.currentPlaylistId();
const seriesXtreamId = Number(this.selectedItem()?.series_id ?? 0);
@@ -354,7 +358,8 @@ export class SerialDetailsPlaybackService {
this.playbackPositionState,
() =>
this.currentPlaylistId() === playlistId &&
Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId
Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId,
scope
);
if (persisted) {
await this.refreshStorePositions(playlistId);
@@ -0,0 +1,223 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
describe('SerialDetailsSeasonWatchService', () => {
let service: SerialDetailsSeasonWatchService;
const savePlaybackPositionsBatch = jest.fn();
const clearPlaybackPositionsBatch = jest.fn();
const snackBarOpen = jest.fn();
const state = { updateMany: jest.fn(), removeMany: jest.fn() };
const position = (contentXtreamId: number): PlaybackPositionData => ({
contentXtreamId,
contentType: 'episode',
seriesXtreamId: 20,
seasonNumber: 1,
episodeNumber: contentXtreamId % 100,
positionSeconds: 2700,
durationSeconds: 2700,
playlistId: 'playlist-1',
});
const markRequest = (ids: number[]) => ({
markWatched: true,
requests: ids.map((id) => ({
contentXtreamId: id,
nextPosition: position(id),
})),
});
const clearRequest = (ids: number[]) => ({
markWatched: false,
requests: ids.map((id) => ({
contentXtreamId: id,
nextPosition: null,
})),
});
beforeEach(() => {
jest.clearAllMocks();
savePlaybackPositionsBatch.mockResolvedValue(undefined);
clearPlaybackPositionsBatch.mockResolvedValue(undefined);
TestBed.configureTestingModule({
providers: [
SerialDetailsSeasonWatchService,
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
savePlaybackPositionsBatch,
clearPlaybackPositionsBatch,
},
},
{ provide: MatSnackBar, useValue: { open: snackBarOpen } },
{
provide: TranslateService,
useValue: {
instant: (key: string, params?: object) =>
params ? `${key}:${JSON.stringify(params)}` : key,
},
},
],
});
service = TestBed.inject(SerialDetailsSeasonWatchService);
});
it('marks a season through one batch save with season feedback', async () => {
const persisted = await service.handle(
markRequest([101, 102]),
'playlist-1',
state,
() => true,
'season'
);
expect(persisted).toBe(true);
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('playlist-1', [
expect.objectContaining({ contentXtreamId: 101 }),
expect.objectContaining({ contentXtreamId: 102 }),
]);
expect(state.updateMany).toHaveBeenCalledTimes(1);
expect(snackBarOpen).toHaveBeenCalledWith(
'XTREAM.SEASON_MARKED_WATCHED:{"count":2}',
undefined,
{ duration: 5000 }
);
});
it('reuses the scope-generic marked key for a series mark', async () => {
await service.handle(
markRequest([101, 201]),
'playlist-1',
state,
() => true,
'series'
);
expect(snackBarOpen).toHaveBeenCalledWith(
'XTREAM.SEASON_MARKED_WATCHED:{"count":2}',
undefined,
{ duration: 5000 }
);
});
it('unmarks a series through one batch clear with series feedback', async () => {
const persisted = await service.handle(
clearRequest([101, 201]),
'playlist-1',
state,
() => true,
'series'
);
expect(persisted).toBe(true);
expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith(
'playlist-1',
[
{ contentXtreamId: 101, contentType: 'episode' },
{ contentXtreamId: 201, contentType: 'episode' },
]
);
expect(state.removeMany).toHaveBeenCalledWith([101, 201]);
expect(snackBarOpen).toHaveBeenCalledWith(
'XTREAM.SERIES_MARKED_UNWATCHED',
undefined,
{ duration: 5000 }
);
});
it('keeps the season unmark feedback on the season scope', async () => {
await service.handle(
clearRequest([101]),
'playlist-1',
state,
() => true,
'season'
);
expect(snackBarOpen).toHaveBeenCalledWith(
'XTREAM.SEASON_MARKED_UNWATCHED',
undefined,
{ duration: 5000 }
);
});
it('reports a series failure with the series key and keeps state', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
savePlaybackPositionsBatch.mockRejectedValue(new Error('boom'));
const persisted = await service.handle(
markRequest([101]),
'playlist-1',
state,
() => true,
'series'
);
expect(persisted).toBe(false);
expect(state.updateMany).not.toHaveBeenCalled();
expect(snackBarOpen).toHaveBeenCalledWith(
'XTREAM.SERIES_WATCH_UPDATE_FAILED',
undefined,
{ duration: 5000 }
);
consoleError.mockRestore();
});
it('suppresses state writes and feedback after navigation, still reporting persisted', async () => {
const persisted = await service.handle(
markRequest([101]),
'playlist-1',
state,
() => false,
'series'
);
expect(persisted).toBe(true);
expect(state.updateMany).not.toHaveBeenCalled();
expect(snackBarOpen).not.toHaveBeenCalled();
});
it('rejects empty requests and re-entrant calls', async () => {
expect(
await service.handle(
{ markWatched: true, requests: [] },
'playlist-1',
state,
() => true,
'series'
)
).toBe(false);
let release!: () => void;
savePlaybackPositionsBatch.mockImplementation(
() => new Promise<void>((resolve) => (release = resolve))
);
const pending = service.handle(
markRequest([101]),
'playlist-1',
state,
() => true,
'season'
);
expect(service.batchRunning()).toBe(true);
expect(
await service.handle(
markRequest([102]),
'playlist-1',
state,
() => true,
'series'
)
).toBe(false);
release();
await pending;
expect(service.batchRunning()).toBe(false);
expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1);
});
});
@@ -3,12 +3,29 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { SeasonContainerSeasonPlaybackToggleRequest } from '@iptvnator/ui/components';
import { SeasonContainerSeriesPlaybackToggleRequest } from '@iptvnator/ui/components';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
// The marked-watched key is scope-generic on purpose ("{{count}} episodes
// marked as watched"); only unmark and failure name their scope.
const WATCH_TOGGLE_FEEDBACK = {
season: {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED',
failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED',
},
series: {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED',
failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED',
},
} as const;
export type SerialDetailsWatchScope = keyof typeof WATCH_TOGGLE_FEEDBACK;
/**
* Season-level bulk watched toggle for the serial details view: one batch
* persistence call, host state update, and user feedback.
* Season- and series-level bulk watched toggle for the serial details view:
* one batch persistence call, host state update, and user feedback.
*/
@Injectable()
export class SerialDetailsSeasonWatchService {
@@ -19,7 +36,7 @@ export class SerialDetailsSeasonWatchService {
readonly batchRunning = signal(false);
async handle(
request: SeasonContainerSeasonPlaybackToggleRequest,
request: SeasonContainerSeriesPlaybackToggleRequest,
playlistId: string,
state: Pick<
SerialDetailsPlaybackPositionState,
@@ -31,8 +48,10 @@ export class SerialDetailsSeasonWatchService {
// into it (episode ids can collide across playlists) nor present
// its contextless snackbar as feedback about the newly opened page.
// The DB write itself is safe — it carries its own playlistId.
stillCurrent: () => boolean
stillCurrent: () => boolean,
scope: SerialDetailsWatchScope = 'season'
): Promise<boolean> {
const feedback = WATCH_TOGGLE_FEEDBACK[scope];
if (!playlistId || request.requests.length === 0 || this.batchRunning()) {
return false;
}
@@ -54,7 +73,7 @@ export class SerialDetailsSeasonWatchService {
return true;
}
state.updateMany(positions);
this.notify('XTREAM.SEASON_MARKED_WATCHED', {
this.notify(feedback.marked, {
count: positions.length,
});
} else {
@@ -71,18 +90,19 @@ export class SerialDetailsSeasonWatchService {
state.removeMany(
request.requests.map((item) => item.contentXtreamId)
);
this.notify('XTREAM.SEASON_MARKED_UNWATCHED');
this.notify(feedback.unmarked);
}
return true;
} catch (error) {
// Nothing was confirmed persisted — keep the rendered state and
// report instead of showing episodes as (un)watched.
console.error(
'[SerialDetailsSeasonWatch] Season watched toggle failed',
'[SerialDetailsSeasonWatch] Watched toggle failed',
{ scope },
error
);
if (stillCurrent()) {
this.notify('XTREAM.SEASON_WATCH_UPDATE_FAILED');
this.notify(feedback.failed);
}
return false;
} finally {
@@ -221,6 +221,9 @@
(seasonPlaybackToggleRequested)="
handleSeasonPlaybackToggleRequested($event)
"
(seriesPlaybackToggleRequested)="
handleSeriesPlaybackToggleRequested($event)
"
(seasonSelected)="onSeasonSelected($event)"
/>
@@ -923,14 +923,23 @@ describe('SerialDetailsComponent', () => {
durationSeconds: 1200,
});
await playbackService.handleSeasonPlaybackToggleRequested({
seasonKey: '1',
markWatched: true,
requests: [
{ contentXtreamId: 1001, nextPosition: seasonPosition(1001, 1) },
{ contentXtreamId: 1002, nextPosition: seasonPosition(1002, 2) },
],
} as never);
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: seasonPosition(1001, 1),
},
{
contentXtreamId: 1002,
nextPosition: seasonPosition(1002, 2),
},
],
} as never,
'season'
);
expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1);
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
@@ -961,14 +970,17 @@ describe('SerialDetailsComponent', () => {
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
await playbackService.handleSeasonPlaybackToggleRequested({
seasonKey: '1',
markWatched: false,
requests: [
{ contentXtreamId: 1001, nextPosition: null },
{ contentXtreamId: 1002, nextPosition: null },
],
} as never);
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: false,
requests: [
{ contentXtreamId: 1001, nextPosition: null },
{ contentXtreamId: 1002, nextPosition: null },
],
} as never,
'season'
);
expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1);
expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
@@ -995,25 +1007,28 @@ describe('SerialDetailsComponent', () => {
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const pending = playbackService.handleSeasonPlaybackToggleRequested({
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
const pending = playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
},
},
},
],
} as never);
],
} as never,
'season'
);
// The user navigates to another playlist while the batch is pending.
const initialPlaylist = currentPlaylist();
@@ -1049,25 +1064,28 @@ describe('SerialDetailsComponent', () => {
SerialDetailsPlaybackService
);
const snackBar = TestBed.inject(MatSnackBar);
await playbackService.handleSeasonPlaybackToggleRequested({
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
},
},
},
],
} as never);
],
} as never,
'season'
);
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
false
@@ -23,6 +23,7 @@ import {
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
SeasonContainerSeasonPlaybackToggleRequest,
SeasonContainerSeriesPlaybackToggleRequest,
} from '@iptvnator/ui/components';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
@@ -394,7 +395,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
handleSeasonPlaybackToggleRequested(
request: SeasonContainerSeasonPlaybackToggleRequest
): Promise<void> {
return this.playback.handleSeasonPlaybackToggleRequested(request);
return this.playback.handleWatchToggleRequested(request, 'season');
}
handleSeriesPlaybackToggleRequested(
request: SeasonContainerSeriesPlaybackToggleRequest
): Promise<void> {
return this.playback.handleWatchToggleRequested(request, 'series');
}
toggleFavorite(): void {
+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 };
}