feat(portals): mark a full season as watched in one click (#1447)

* feat(portals): mark a full season as watched in one click

Series detail pages on both Xtream and Stalker portals get a season-level
watched toggle next to "Download season": marking writes full-progress
rows for the unwatched episodes only (real durations survive), a fully
watched season flips the action to unwatch-all.

Persistence goes through new batch IPC channels
(DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH, one SQLite transaction with
onConflictDoUpdate().run(); the PWA data source rewrites its
localStorage blob once). Stalker deliberately bypasses the batch IPC
and loops the existing position-mutation queue so legacy-row
reconciliation still runs and the queue coalesces to a single reload;
partial failures surface a dedicated snackbar.

Also removes the dead toggleEpisodeWatched store method, splits
season-container/serial-details-playback under the max-lines cap
(season-watch-toggle.util.ts, SerialDetailsSeasonWatchService), and
classifies *.spec-data.ts fixtures under the test max-lines ceiling
(baseline shrinks by main.preload.spec-data.ts).

Closes #1442

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): guard stale season batches and split partial-unwatch feedback

Review follow-up (Codex on #1447):
- A season batch completing after the user navigated to another series
  or playlist no longer writes the old series' rows into the freshly
  reset position state (episode ids can collide across playlists); the
  Xtream host captures the playlist/series identity before awaiting and
  skips the rendered-state mutation when it changed. The DB write is
  unaffected — it carries its own playlistId.
- A partially failed "mark season as unwatched" on Stalker now reports
  a dedicated SEASON_MARKED_UNWATCHED_PARTIAL message instead of the
  watch-direction "marked" text; translated into all 18 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): exclude the playing episode from season marking and count partial saves

Second review round (Codex on #1447):
- The episode currently playing (inline or in an external session, or
  with a launch in flight) is excluded from a season's mark-watched
  batch: the player persists its live position every ~15 s and would
  immediately overwrite the just-written full-progress row. The button
  count reflects the exclusion and the action disables when nothing is
  markable. Unmarking still clears such an episode — the recreated
  in-progress row reflects live playback truthfully.
- A Stalker StalkerSeriesPositionPartialSaveError (scoped watched row
  saved and published, only legacy cleanup failed) now counts as a
  watched success instead of feeding false total-failure feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): gate stale season-batch snackbars on the originating page

Third review round (Codex on #1447): a batch resolving after the user
navigated away no longer shows its contextless success/error snackbar
on the newly opened detail page — the same ownership check that guards
the state mutation now guards the feedback too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): sync catalog progress badges after toggles and gate Stalker feedback

Fourth review round (Codex on #1447):
- Any Xtream watched toggle (single episode or season batch) now
  refreshes XtreamStore.loadAllPositions after persisting — the catalog
  reads series-progress badges from the store, which otherwise loads
  positions once per playlist, so returning from the detail kept stale
  badges. Skipped when the playlist changed mid-flight (the store then
  belongs to the other playlist; its own init reloads positions).
- Stalker's season snackbars are gated on the captured playlist/series
  identity, matching the Xtream ownership guard — a batch draining after
  navigation no longer reports on the newly opened page.
- Stalker season-toggle specs moved to stalker-series-view.season-watch
  .spec.ts with their own harness; both prior spec files sat at the
  1200-line test ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: describe the season watched toggle in CLAUDE.md

Fifth review round (Codex on #1447): the canonical Seasons entry in the
VOD/Series detail section now covers the bulk toggle, its playing-episode
exclusion, both persistence paths, catalog badge sync, and the
stale-completion contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): let only the latest positions load patch the Xtream store

Sixth review round (Codex on #1447): loadAllPositions is now
latest-load-wins — a fetch superseded while in flight (playlist switch
before getAllPlaybackPositions resolves) no longer patches the singleton
store with the previous playlist's position maps, which could leave the
new catalog showing the old playlist's progress badges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: reflect the spec-data max-lines classification in CLAUDE.md and AGENTS.md

Seventh review round (Codex on #1447): both canonical max-lines
descriptions now list **/*.spec-data.ts among the test-ceiling globs so
future agents neither treat these fixtures as production files nor
remove the exemption unknowingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): parse "N min" durations when marking episodes watched

Eighth review round (Codex on #1447): Stalker VOD episodes report
durations like "45 min", which parseDuration could not read — bulk (and
single) mark-watched then persisted 1/1-second rows. The minute format
now parses to seconds, matching what the removed legacy store method
already handled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): parse compound hour durations and cover the toggle end-to-end

Ninth review round (Codex on #1447):
- parseDuration now reads the compound "1h 30min" form the Xtream
  fixtures emit (hour group optional, so "45 min" keeps working) —
  bulk-marked episodes no longer persist a minutes-only duration.
- New Playwright coverage exercises the season toggle through the real
  UI on both portals: Xtream (category → series detail → mark →
  reload-persistence → unmark) and Stalker (embedded-series flow,
  mark → unmark with the item's actual episode count).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): refresh Stalker catalog progress badges after watched toggles

Tenth review round (Codex on #1447): the Stalker mirror of the Xtream
catalog sync — StalkerCatalogFacadeService loads its position maps once
per playlist and the runtime bridge only pushes external-player updates,
so renderer-initiated toggles left grid badges stale. The series view
now calls the facade's new ownership-checked refreshPositions after the
season batch (including partial successes) and after single toggles;
the reload is latest-load-wins like the Xtream store fix. Optional
injection keeps collection-detail mounts outside the catalog working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(portals): cover the season toggle batch IPC end-to-end in Electron

Eleventh review round (Codex on #1447): the new Electron E2E marks a
season through the real UI, asserts the eight SQLite rows written by
DB_SAVE_PLAYBACK_POSITIONS_BATCH directly through the preload bridge,
proves persistence with a full app relaunch (renderer and main process
die, so state can only come from the database file), and clears again
through DB_CLEAR_PLAYBACK_POSITIONS_BATCH back to zero rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): keep watched rows out of the series resume target

Twelfth review round (Codex on #1447): a watched position row — a
natural finish or a manual/bulk "mark watched" marker — is a completion
record, not resumable progress. Continue Watching no longer auto-plays
such an episode at its end; the handoff stays detail-only and the series
page's quick-start picks the first unwatched episode instead. Card
progress bars and SxxEyy badges keep their current source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): fail closed on refresh reads and gate batch APIs by capability

Thirteenth review round (Codex on #1447):
- Position-cache refreshes now use a failure-propagating read
  (getAllPlaybackPositionsOrThrow through the Electron data source): a
  transient IPC failure rejects instead of masquerading as an empty
  list, so a populated store/facade cache stays stale-but-populated
  rather than being wiped. All load/refresh call sites handle the new
  rejection (init loads may retry on the next activation; post-toggle
  refreshes log and keep the snackbar flow).
- The season-batch bridge methods joined playbackPositionStorageMethods,
  so a bridge lacking them degrades to the in-memory path wholesale
  instead of throwing mid-action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-15 21:03:14 +02:00
1 parent 00000de798
commit 7fc9380bff
79 files changed
+3317 -199

No files matched your search

@@ -7,6 +7,23 @@ import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { PlaybackPositionRuntimeBridgeService } from './playback-position-runtime-bridge.service';
const batchSaveItems: PlaybackPositionData[] = [
createPosition(),
createPosition({
contentXtreamId: 101,
contentType: 'episode',
seriesXtreamId: 200,
}),
];
const batchClearItems: {
contentXtreamId: number;
contentType: 'vod' | 'episode';
}[] = [
{ contentXtreamId: 100, contentType: 'vod' },
{ contentXtreamId: 101, contentType: 'episode' },
];
describe('PlaybackPositionRuntimeBridgeService', () => {
let service: PlaybackPositionRuntimeBridgeService;
let injector: DestroyableInjector;
@@ -323,6 +340,108 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
await expect(operation.invokeLenient(service)).rejects.toBe(error);
});
});
describe.each([
{
name: 'batch save',
items: batchSaveItems as unknown[],
installBridge: (implementation: jest.Mock) => {
window.electron = {
...window.electron,
dbSavePlaybackPositionsBatch: implementation,
} as unknown as typeof window.electron;
},
invoke: (target: PlaybackPositionRuntimeBridgeService) =>
target.savePlaybackPositionsBatch(
'playlist-1',
batchSaveItems
),
invokeEmpty: (target: PlaybackPositionRuntimeBridgeService) =>
target.savePlaybackPositionsBatch('playlist-1', []),
},
{
name: 'batch clear',
items: batchClearItems as unknown[],
installBridge: (implementation: jest.Mock) => {
window.electron = {
...window.electron,
dbClearPlaybackPositionsBatch: implementation,
} as unknown as typeof window.electron;
},
invoke: (target: PlaybackPositionRuntimeBridgeService) =>
target.clearPlaybackPositionsBatch(
'playlist-1',
batchClearItems
),
invokeEmpty: (target: PlaybackPositionRuntimeBridgeService) =>
target.clearPlaybackPositionsBatch('playlist-1', []),
},
])('$name persistence', (operation) => {
it('silently no-ops when the storage capability is unavailable', async () => {
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(operation.invoke(service)).resolves.toBeUndefined();
expect(bridgeMethod).not.toHaveBeenCalled();
});
it('silently no-ops on an empty item list', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(
operation.invokeEmpty(service)
).resolves.toBeUndefined();
expect(bridgeMethod).not.toHaveBeenCalled();
});
it('invokes the batch bridge method with the playlist and items', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(operation.invoke(service)).resolves.toBeUndefined();
expect(bridgeMethod).toHaveBeenCalledWith(
'playlist-1',
operation.items
);
});
it.each([{ success: false }, {}, undefined])(
'rejects a non-success result %#',
async (result) => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockResolvedValue(result));
await expect(operation.invoke(service)).rejects.toThrow(
'did not succeed'
);
}
);
it('rejects when the batch bridge method is unavailable', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
await expect(operation.invoke(service)).rejects.toThrow(
'method is unavailable'
);
});
it('propagates rejected IPC', async () => {
const error = new Error('database is locked');
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockRejectedValue(error));
await expect(operation.invoke(service)).rejects.toBe(error);
});
});
});
function createPosition(
@@ -33,6 +33,17 @@ type PlaybackPositionElectronBridge = Partial<{
contentXtreamId: number,
contentType: PlaybackPositionContentType
) => Promise<{ success: boolean }>;
dbSavePlaybackPositionsBatch: (
playlistId: string,
items: PlaybackPositionData[]
) => Promise<{ success: boolean }>;
dbClearPlaybackPositionsBatch: (
playlistId: string,
items: {
contentXtreamId: number;
contentType: PlaybackPositionContentType;
}[]
) => Promise<{ success: boolean }>;
onPlaybackPositionUpdate: (
callback: (data: PlaybackPositionData) => void
) => () => void;
@@ -197,6 +208,57 @@ export class PlaybackPositionRuntimeBridgeService {
}
}
async savePlaybackPositionsBatch(
playlistId: string,
items: PlaybackPositionData[]
): Promise<void> {
if (!this.supportsStorage || items.length === 0) {
return;
}
const bridge = this.bridge;
if (typeof bridge?.dbSavePlaybackPositionsBatch !== 'function') {
throw new Error(
'Playback position batch save method is unavailable'
);
}
const result = await bridge.dbSavePlaybackPositionsBatch(
playlistId,
items
);
if (result?.success !== true) {
throw new Error('Playback position batch save did not succeed');
}
}
async clearPlaybackPositionsBatch(
playlistId: string,
items: {
contentXtreamId: number;
contentType: PlaybackPositionContentType;
}[]
): Promise<void> {
if (!this.supportsStorage || items.length === 0) {
return;
}
const bridge = this.bridge;
if (typeof bridge?.dbClearPlaybackPositionsBatch !== 'function') {
throw new Error(
'Playback position batch clear method is unavailable'
);
}
const result = await bridge.dbClearPlaybackPositionsBatch(
playlistId,
items
);
if (result?.success !== true) {
throw new Error('Playback position batch clear did not succeed');
}
}
onPlaybackPositionUpdate(
callback: (data: PlaybackPositionData) => void
): (() => void) | undefined {
@@ -20,6 +20,8 @@ describe('PlaybackPositionService', () => {
| 'getAllPlaybackPositions'
| 'clearAllPlaybackPositions'
| 'clearPlaybackPosition'
| 'savePlaybackPositionsBatch'
| 'clearPlaybackPositionsBatch'
>
>;
@@ -32,6 +34,8 @@ describe('PlaybackPositionService', () => {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
savePlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined),
clearPlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined),
};
injector = Injector.create({
@@ -150,6 +154,54 @@ describe('PlaybackPositionService', () => {
service.clearPlaybackPosition('playlist-1', 100, 'vod')
).resolves.toBeUndefined();
});
it('delegates batch playback-position writes through the runtime bridge', async () => {
const items = [
createPosition(),
createPosition({
contentXtreamId: 101,
contentType: 'episode',
seriesXtreamId: 200,
}),
];
const clearItems: {
contentXtreamId: number;
contentType: 'vod' | 'episode';
}[] = [
{ contentXtreamId: 100, contentType: 'vod' },
{ contentXtreamId: 101, contentType: 'episode' },
];
await service.savePlaybackPositionsBatch('playlist-1', items);
await service.clearPlaybackPositionsBatch('playlist-1', clearItems);
expect(bridge.savePlaybackPositionsBatch).toHaveBeenCalledWith(
'playlist-1',
items
);
expect(bridge.clearPlaybackPositionsBatch).toHaveBeenCalledWith(
'playlist-1',
clearItems
);
});
it('propagates batch failures to the caller instead of swallowing them', async () => {
const saveError = new Error('batch save failed');
const clearError = new Error('batch clear failed');
bridge.savePlaybackPositionsBatch.mockRejectedValue(saveError);
bridge.clearPlaybackPositionsBatch.mockRejectedValue(clearError);
await expect(
service.savePlaybackPositionsBatch('playlist-1', [
createPosition(),
])
).rejects.toBe(saveError);
await expect(
service.clearPlaybackPositionsBatch('playlist-1', [
{ contentXtreamId: 100, contentType: 'vod' },
])
).rejects.toBe(clearError);
});
});
function createPosition(
@@ -84,6 +84,17 @@ export class PlaybackPositionService {
}
}
/**
* Failure-propagating read for cache refreshes: a swallowed error would
* surface as an authoritative empty list and let a transient IPC failure
* wipe an already-populated position cache.
*/
getAllPlaybackPositionsOrThrow(
playlistId: string
): Promise<PlaybackPositionData[]> {
return this.playbackPositionBridge.getAllPlaybackPositions(playlistId);
}
async clearAllPlaybackPositions(playlistId: string): Promise<void> {
try {
await this.playbackPositionBridge.clearAllPlaybackPositions(
@@ -109,4 +120,27 @@ export class PlaybackPositionService {
console.error('Error clearing playback position:', error);
}
}
// The batch methods deliberately propagate failures (no swallow):
// season-level bulk toggles must show an error and keep the UI state
// untouched when nothing was persisted.
savePlaybackPositionsBatch(
playlistId: string,
items: PlaybackPositionData[]
): Promise<void> {
return this.playbackPositionBridge.savePlaybackPositionsBatch(
playlistId,
items
);
}
clearPlaybackPositionsBatch(
playlistId: string,
items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[]
): Promise<void> {
return this.playbackPositionBridge.clearPlaybackPositionsBatch(
playlistId,
items
);
}
}
@@ -92,6 +92,8 @@ describe('RuntimeCapabilitiesService', () => {
dbGetAllPlaybackPositions: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
dbClearPlaybackPosition: jest.fn(),
dbSavePlaybackPositionsBatch: jest.fn(),
dbClearPlaybackPositionsBatch: jest.fn(),
onPlaybackPositionUpdate: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
@@ -468,6 +470,15 @@ describe('RuntimeCapabilitiesService', () => {
dbClearPlaybackPosition: jest.fn(),
};
// The season-batch methods are part of the storage contract too.
expect(service.supportsPlaybackPositionStorage).toBe(false);
testWindow.electron = {
...testWindow.electron,
dbSavePlaybackPositionsBatch: jest.fn(),
dbClearPlaybackPositionsBatch: jest.fn(),
};
expect(service.supportsPlaybackPositionStorage).toBe(true);
expect(service.supportsPlaybackPositionUpdates).toBe(false);
@@ -523,6 +534,8 @@ function createXtreamSqliteBridge(): Record<string, jest.Mock> {
dbAddRecentItem: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
dbClearPlaybackPosition: jest.fn(),
dbSavePlaybackPositionsBatch: jest.fn(),
dbClearPlaybackPositionsBatch: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbCreatePlaylist: jest.fn(),
dbDeletePlaylist: jest.fn(),
@@ -5,6 +5,9 @@ export type RuntimeEnvironment = 'electron' | 'pwa';
type RuntimeElectronBridge = Partial<ElectronBridgeApi>;
// The full method set the position-storage layer may invoke — including the
// season-batch variants, so a bridge lacking them degrades to the in-memory
// path wholesale instead of throwing mid-action.
const playbackPositionStorageMethods = [
'dbSavePlaybackPosition',
'dbGetPlaybackPosition',
@@ -13,6 +16,8 @@ const playbackPositionStorageMethods = [
'dbGetAllPlaybackPositions',
'dbClearAllPlaybackPositions',
'dbClearPlaybackPosition',
'dbSavePlaybackPositionsBatch',
'dbClearPlaybackPositionsBatch',
];
@Injectable({ providedIn: 'root' })