fix(playback): expose strict position persistence

This commit is contained in:
4gray committed 2026-07-30 10:55:57 +02:00
1 parent ac12272db6
commit 7b6d576fef
8 files changed
+434 -29

No files matched your search

@@ -0,0 +1,128 @@
import {
DestroyableInjector,
Injector,
runInInjectionContext,
} from '@angular/core';
import {
IXtreamDataSource,
XTREAM_DATA_SOURCE,
} from '@iptvnator/portal/xtream/data-access';
import {
PlaybackPositionRuntimeBridgeService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { AppPortalPlaybackPositionsService } from './portal-playback-positions.service';
interface PersistenceMocks {
clearDataSource: jest.Mock;
clearRuntime: jest.Mock;
saveDataSource: jest.Mock;
saveRuntime: jest.Mock;
}
interface PersistenceHarness {
mocks: PersistenceMocks;
service: AppPortalPlaybackPositionsService;
}
const position: PlaybackPositionData = {
contentXtreamId: 101,
contentType: 'episode',
playlistId: 'playlist-1',
positionSeconds: 42,
};
describe('AppPortalPlaybackPositionsService strict persistence', () => {
let injector: DestroyableInjector;
function createHarness(
supportsXtreamSqliteDataSource: boolean
): PersistenceHarness {
const mocks: PersistenceMocks = {
clearDataSource: jest.fn().mockResolvedValue(undefined),
clearRuntime: jest.fn().mockResolvedValue(undefined),
saveDataSource: jest.fn().mockResolvedValue(undefined),
saveRuntime: jest.fn().mockResolvedValue(undefined),
};
const dataSource = {
clearPlaybackPosition: mocks.clearDataSource,
savePlaybackPosition: mocks.saveDataSource,
} as unknown as IXtreamDataSource;
injector = Injector.create({
providers: [
AppPortalPlaybackPositionsService,
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource },
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
clearPlaybackPositionOrThrow: mocks.clearRuntime,
savePlaybackPositionOrThrow: mocks.saveRuntime,
},
},
],
});
return {
mocks,
service: runInInjectionContext(injector, () =>
injector.get(AppPortalPlaybackPositionsService)
),
};
}
afterEach(() => injector.destroy());
describe.each([
{
dataSourceMock: (mocks: PersistenceMocks) =>
mocks.saveDataSource,
invoke: (service: AppPortalPlaybackPositionsService) =>
service.savePlaybackPositionOrThrow(
'playlist-1',
position
),
runtimeMock: (mocks: PersistenceMocks) => mocks.saveRuntime,
},
{
dataSourceMock: (mocks: PersistenceMocks) =>
mocks.clearDataSource,
invoke: (service: AppPortalPlaybackPositionsService) =>
service.clearPlaybackPositionOrThrow(
'playlist-1',
101,
'episode'
),
runtimeMock: (mocks: PersistenceMocks) => mocks.clearRuntime,
},
])('strict operation %#', (operation) => {
it('propagates Electron bridge rejection', async () => {
const { mocks, service } = createHarness(true);
const error = new Error('Electron write failed');
operation.runtimeMock(mocks).mockRejectedValue(error);
await expect(operation.invoke(service)).rejects.toBe(error);
expect(operation.dataSourceMock(mocks)).not.toHaveBeenCalled();
});
it('propagates PWA data-source rejection', async () => {
const { mocks, service } = createHarness(false);
const error = new Error('localStorage quota exceeded');
operation.dataSourceMock(mocks).mockRejectedValue(error);
await expect(operation.invoke(service)).rejects.toBe(error);
expect(operation.runtimeMock(mocks)).not.toHaveBeenCalled();
});
it('keeps a partial bridge on the selected PWA data source', async () => {
const { mocks, service } = createHarness(false);
await expect(operation.invoke(service)).resolves.toBeUndefined();
expect(operation.dataSourceMock(mocks)).toHaveBeenCalledTimes(1);
expect(operation.runtimeMock(mocks)).not.toHaveBeenCalled();
});
});
});
@@ -3,6 +3,10 @@ import {
PORTAL_PLAYBACK_POSITIONS,
PortalPlaybackPositions,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionRuntimeBridgeService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
PlaybackPositionData,
XTREAM_DATA_SOURCE,
@@ -15,6 +19,12 @@ export class AppPortalPlaybackPositionsService
implements PortalPlaybackPositions
{
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
private readonly useRuntimePersistence = inject(
RuntimeCapabilitiesService
).supportsXtreamSqliteDataSource;
private readonly runtimeBridge = inject(
PlaybackPositionRuntimeBridgeService
);
async savePlaybackPosition(
playlistId: string,
@@ -23,6 +33,21 @@ export class AppPortalPlaybackPositionsService
await this.dataSource.savePlaybackPosition(playlistId, data);
}
async savePlaybackPositionOrThrow(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
if (this.useRuntimePersistence) {
await this.runtimeBridge.savePlaybackPositionOrThrow(
playlistId,
data
);
return;
}
await this.dataSource.savePlaybackPosition(playlistId, data);
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
@@ -62,6 +87,27 @@ export class AppPortalPlaybackPositionsService
contentType
);
}
async clearPlaybackPositionOrThrow(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
if (this.useRuntimePersistence) {
await this.runtimeBridge.clearPlaybackPositionOrThrow(
playlistId,
contentXtreamId,
contentType
);
return;
}
await this.dataSource.clearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
}
export const providePortalPlaybackPositions = () => [
@@ -6,6 +6,10 @@ export interface PortalPlaybackPositions {
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
savePlaybackPositionOrThrow(
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
@@ -21,6 +25,11 @@ export interface PortalPlaybackPositions {
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
clearPlaybackPositionOrThrow(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
}
export const PORTAL_PLAYBACK_POSITIONS =
@@ -201,7 +201,7 @@
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="
handlePlaybackToggleRequested($event)
handlePlaybackToggleRequestedFromUi($event)
"
#seasonContainer
/>
@@ -118,6 +118,26 @@ interface SeriesPositionContext {
export class StalkerSeriesViewComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly migrationPlaybackPositions = {
savePlaybackPosition: (
playlistId: string,
data: PlaybackPositionData
) =>
this.playbackPositions.savePlaybackPositionOrThrow(
playlistId,
data
),
clearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
this.playbackPositions.clearPlaybackPositionOrThrow(
playlistId,
contentXtreamId,
contentType
),
};
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
@@ -412,6 +432,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const seriesId = item ? this.toSeriesId(item.id) : 0;
if (
!playlistId ||
data.contentType !== 'episode' ||
data.playlistId !== playlistId ||
data.seriesXtreamId !== seriesId
@@ -995,6 +1016,19 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
}
handlePlaybackToggleRequestedFromUi(
request: SeasonContainerPlaybackToggleRequest
): void {
void this.handlePlaybackToggleRequested(request).catch(
(error: unknown) => {
this.logger.error(
'Failed to update series playback position',
error
);
}
);
}
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
@@ -1255,7 +1289,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
let clearedLegacy: boolean;
try {
clearedLegacy = await saveStalkerSeriesPosition({
repository: this.playbackPositions,
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
@@ -1334,7 +1368,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.legacyPositionByTrackingId().get(contentXtreamId);
return this.enqueueSeriesPositionMutation(context, async () => {
const clearedLegacy = await clearStalkerSeriesPosition({
repository: this.playbackPositions,
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
@@ -22,7 +22,6 @@ import {
} from '@iptvnator/services';
import { EMPTY, of } from 'rxjs';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
const PLAYLIST_ID = 'playlist-1';
const SECOND_PLAYLIST_ID = 'playlist-2';
const SERIES_A_ID = 100;
@@ -30,12 +29,10 @@ const SERIES_B_ID = 200;
const SCOPED_A_ID = 604391373;
const LEGACY_ID = 624320047;
const REGULAR_ID = 91189090;
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
@@ -43,7 +40,6 @@ function createDeferred<T>(): Deferred<T> {
});
return { promise, resolve };
}
function createVodItem(seriesId: number): StalkerVodSource {
return {
id: String(seriesId),
@@ -55,7 +51,6 @@ function createVodItem(seriesId: number): StalkerVodSource {
},
};
}
function createRegularItem(seriesId: number): StalkerVodSource {
return {
id: String(seriesId),
@@ -67,7 +62,6 @@ function createRegularItem(seriesId: number): StalkerVodSource {
},
};
}
function createSeason(
seriesId: number,
episodes: VodSeriesSeasonVm['episodes'] = []
@@ -82,7 +76,6 @@ function createSeason(
isExpanded: false,
};
}
function createProviderEpisode(
id = 'provider-episode-1',
episodeNumber = 1
@@ -93,7 +86,6 @@ function createProviderEpisode(
name: episodeNumber === 1 ? 'Pilot' : `Episode ${episodeNumber}`,
};
}
function createPosition(
overrides: Partial<PlaybackPositionData> = {}
): PlaybackPositionData {
@@ -109,7 +101,6 @@ function createPosition(
...overrides,
};
}
describe('StalkerSeriesViewComponent position compatibility', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
let repositoryRows: PlaybackPositionData[];
@@ -117,7 +108,6 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
let runtimePositionListener:
| ((position: PlaybackPositionData) => void)
| undefined;
const selectedContentType = signal<'series' | 'vod'>('vod');
const selectedItem = signal<StalkerVodSource | null>(
createVodItem(SERIES_A_ID)
@@ -131,15 +121,15 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
const getSeriesPlaybackPositions = jest.fn();
const savePlaybackPosition = jest.fn();
const clearPlaybackPosition = jest.fn();
const savePlaybackPositionOrThrow = jest.fn();
const clearPlaybackPositionOrThrow = jest.fn();
const onPlaybackPositionUpdate = jest.fn();
async function settle(): Promise<void> {
for (let pass = 0; pass < 4; pass++) {
fixture.detectChanges();
await Promise.resolve();
}
}
async function startWithLoadedEpisode(
seriesId = SERIES_A_ID
): Promise<number> {
@@ -156,11 +146,8 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
createSeason(seriesId, [createProviderEpisode()]),
]);
await settle();
return Number(
fixture.componentInstance.mappedSeasons()['1'][0].id
);
return Number(fixture.componentInstance.mappedSeasons()['1'][0].id);
}
async function startPendingTwoEpisodeLoad(): Promise<{
firstPosition: PlaybackPositionData;
pendingLoad: Deferred<PlaybackPositionData[]>;
@@ -194,7 +181,6 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
await settle();
return { firstPosition, pendingLoad, secondPosition };
}
beforeEach(async () => {
repositoryRows = [];
repositoryOrder = [];
@@ -214,8 +200,7 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
seriesXtreamId: number
): Promise<PlaybackPositionData[]> =>
repositoryRows.filter(
(position) =>
position.seriesXtreamId === seriesXtreamId
(position) => position.seriesXtreamId === seriesXtreamId
)
);
savePlaybackPosition.mockReset();
@@ -224,13 +209,9 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
_playlistId: string,
position: PlaybackPositionData
): Promise<void> => {
repositoryOrder.push(
`save:${position.contentXtreamId}`
);
repositoryOrder.push(`save:${position.contentXtreamId}`);
repositoryRows = repositoryRows.filter(
(row) =>
row.contentXtreamId !==
position.contentXtreamId
(row) => row.contentXtreamId !== position.contentXtreamId
);
repositoryRows.push(position);
}
@@ -247,6 +228,12 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
);
}
);
savePlaybackPositionOrThrow
.mockReset()
.mockImplementation(savePlaybackPosition);
clearPlaybackPositionOrThrow
.mockReset()
.mockImplementation(clearPlaybackPosition);
onPlaybackPositionUpdate.mockReset();
onPlaybackPositionUpdate.mockImplementation(
(
@@ -284,6 +271,8 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
getSeriesPlaybackPositions,
savePlaybackPosition,
clearPlaybackPosition,
savePlaybackPositionOrThrow,
clearPlaybackPositionOrThrow,
},
},
{
@@ -366,6 +355,7 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
afterEach(() => {
fixture.destroy();
jest.restoreAllMocks();
});
it('reconciles positions that arrive before lazy episodes', async () => {
@@ -512,6 +502,33 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
expect(repositoryRows).toEqual([latestPosition]);
});
it('keeps legacy progress when the strict UI save rejects', async () => {
const legacyPosition = createPosition({
contentXtreamId: LEGACY_ID,
positionSeconds: 22,
});
repositoryRows = [legacyPosition];
await startWithLoadedEpisode();
savePlaybackPositionOrThrow.mockRejectedValueOnce(new Error('fail'));
const errorLog = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(
fixture.componentInstance.handlePlaybackToggleRequestedFromUi({
contentXtreamId: SCOPED_A_ID,
nextPosition: createPosition({ positionSeconds: 70 }),
})
).toBeUndefined();
await settle();
await fixture.whenStable();
expect(errorLog).toHaveBeenCalled();
expect(clearPlaybackPositionOrThrow).not.toHaveBeenCalled();
expect(
fixture.componentInstance.episodePlaybackPositions()
.get(SCOPED_A_ID)?.positionSeconds
).toBe(22);
});
it('ignores a deferred series-A response after selection changes to series B', async () => {
const seriesAResponse = createDeferred<
PlaybackPositionData[]
@@ -968,7 +985,7 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
const { firstPosition, pendingLoad, secondPosition } =
await startPendingTwoEpisodeLoad();
const saveError = new Error('save failed');
savePlaybackPosition.mockRejectedValue(saveError);
savePlaybackPositionOrThrow.mockRejectedValue(saveError);
const mutation =
fixture.componentInstance.handlePlaybackToggleRequested({
@@ -199,6 +199,130 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
expect(service.onPlaybackPositionUpdate(callback)).toBe(unsubscribe);
expect(onPlaybackPositionUpdate).toHaveBeenCalledWith(callback);
});
describe.each([
{
name: 'save',
installBridge: (implementation: jest.Mock) => {
window.electron = {
...window.electron,
dbSavePlaybackPosition: implementation,
} as unknown as typeof window.electron;
},
invokeLenient: (
target: PlaybackPositionRuntimeBridgeService
) => target.savePlaybackPosition('playlist-1', createPosition()),
invokeStrict: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.savePlaybackPositionOrThrow(
'playlist-1',
createPosition()
),
},
{
name: 'clear',
installBridge: (implementation: jest.Mock) => {
window.electron = {
...window.electron,
dbClearPlaybackPosition: implementation,
} as unknown as typeof window.electron;
},
invokeLenient: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.clearPlaybackPosition(
'playlist-1',
100,
'vod'
),
invokeStrict: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.clearPlaybackPositionOrThrow(
'playlist-1',
100,
'vod'
),
},
])('$name persistence', (operation) => {
it('accepts only an explicit success result', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(
jest.fn().mockResolvedValue({ success: true })
);
await expect(operation.invokeStrict(service)).resolves.toBeUndefined();
});
it('propagates rejected IPC', async () => {
const error = new Error('database is locked');
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockRejectedValue(error));
await expect(operation.invokeStrict(service)).rejects.toBe(error);
});
it.each([{ success: false }, {}, undefined])(
'rejects a non-success result %#',
async (result) => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockResolvedValue(result));
await expect(operation.invokeStrict(service)).rejects.toThrow(
'did not succeed'
);
}
);
it('rejects when the storage capability is unavailable', async () => {
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(operation.invokeStrict(service)).rejects.toThrow(
'storage is unavailable'
);
expect(bridgeMethod).not.toHaveBeenCalled();
});
it('rejects when the expected bridge method is unavailable', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
await expect(operation.invokeStrict(service)).rejects.toThrow(
'method is unavailable'
);
});
it.each([{ success: false }, {}, undefined])(
'ignores a non-success result through the lenient method %#',
async (result) => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockResolvedValue(result));
await expect(
operation.invokeLenient(service)
).resolves.toBeUndefined();
}
);
it('resolves the lenient method when its bridge method is missing', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
await expect(
operation.invokeLenient(service)
).resolves.toBeUndefined();
});
it('propagates rejected IPC through the lenient method', async () => {
const error = new Error('database is locked');
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockRejectedValue(error));
await expect(operation.invokeLenient(service)).rejects.toBe(error);
});
});
});
function createPosition(
@@ -65,6 +65,27 @@ export class PlaybackPositionRuntimeBridgeService {
await this.bridge?.dbSavePlaybackPosition?.(playlistId, data);
}
async savePlaybackPositionOrThrow(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
if (!this.supportsStorage) {
throw new Error('Playback position storage is unavailable');
}
const bridge = this.bridge;
if (typeof bridge?.dbSavePlaybackPosition !== 'function') {
throw new Error(
'Playback position save method is unavailable'
);
}
const result = await bridge.dbSavePlaybackPosition(playlistId, data);
if (result?.success !== true) {
throw new Error('Playback position save did not succeed');
}
}
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
@@ -150,6 +171,32 @@ export class PlaybackPositionRuntimeBridgeService {
);
}
async clearPlaybackPositionOrThrow(
playlistId: string,
contentXtreamId: number,
contentType: PlaybackPositionContentType
): Promise<void> {
if (!this.supportsStorage) {
throw new Error('Playback position storage is unavailable');
}
const bridge = this.bridge;
if (typeof bridge?.dbClearPlaybackPosition !== 'function') {
throw new Error(
'Playback position clear method is unavailable'
);
}
const result = await bridge.dbClearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
if (result?.success !== true) {
throw new Error('Playback position clear did not succeed');
}
}
onPlaybackPositionUpdate(
callback: (data: PlaybackPositionData) => void
): (() => void) | undefined {