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