From 7b6d576fef8f8cafca40936615fa2067bf23ab1b Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 10:50:46 +0200 Subject: [PATCH] fix(playback): expose strict position persistence --- .../portal-playback-positions.service.spec.ts | 128 ++++++++++++++++++ .../portal-playback-positions.service.ts | 46 +++++++ .../util/src/lib/portal-playback-positions.ts | 9 ++ .../stalker-series-view.component.html | 2 +- .../stalker-series-view.component.ts | 38 +++++- ...series-view.position-compatibility.spec.ts | 69 ++++++---- ...ck-position-runtime-bridge.service.spec.ts | 124 +++++++++++++++++ ...layback-position-runtime-bridge.service.ts | 47 +++++++ 8 files changed, 434 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/services/portal-playback-positions.service.spec.ts diff --git a/apps/web/src/app/services/portal-playback-positions.service.spec.ts b/apps/web/src/app/services/portal-playback-positions.service.spec.ts new file mode 100644 index 000000000..f6fdf4f0b --- /dev/null +++ b/apps/web/src/app/services/portal-playback-positions.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/web/src/app/services/portal-playback-positions.service.ts b/apps/web/src/app/services/portal-playback-positions.service.ts index ba987aa98..cb6db69ed 100644 --- a/apps/web/src/app/services/portal-playback-positions.service.ts +++ b/apps/web/src/app/services/portal-playback-positions.service.ts @@ -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 { + 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 { + if (this.useRuntimePersistence) { + await this.runtimeBridge.clearPlaybackPositionOrThrow( + playlistId, + contentXtreamId, + contentType + ); + return; + } + + await this.dataSource.clearPlaybackPosition( + playlistId, + contentXtreamId, + contentType + ); + } } export const providePortalPlaybackPositions = () => [ diff --git a/libs/portal/shared/util/src/lib/portal-playback-positions.ts b/libs/portal/shared/util/src/lib/portal-playback-positions.ts index 0630a1f07..f77682c39 100644 --- a/libs/portal/shared/util/src/lib/portal-playback-positions.ts +++ b/libs/portal/shared/util/src/lib/portal-playback-positions.ts @@ -6,6 +6,10 @@ export interface PortalPlaybackPositions { playlistId: string, data: PlaybackPositionData ): Promise; + savePlaybackPositionOrThrow( + playlistId: string, + data: PlaybackPositionData + ): Promise; getPlaybackPosition( playlistId: string, contentXtreamId: number, @@ -21,6 +25,11 @@ export interface PortalPlaybackPositions { contentXtreamId: number, contentType: 'vod' | 'episode' ): Promise; + clearPlaybackPositionOrThrow( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise; } export const PORTAL_PLAYBACK_POSITIONS = diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 5f7615f50..07ae93308 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -201,7 +201,7 @@ (episodeClicked)="onEpisodeClicked($event)" (episodeDownloadRequested)="downloadEpisode($event)" (playbackToggleRequested)=" - handlePlaybackToggleRequested($event) + handlePlaybackToggleRequestedFromUi($event) " #seasonContainer /> diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 568de3646..f21b184a6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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 { 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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts index bc88b8662..98b04d49e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts @@ -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 { promise: Promise; resolve: (value: T) => void; } - function createDeferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((promiseResolve) => { @@ -43,7 +40,6 @@ function createDeferred(): Deferred { }); 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 { @@ -109,7 +101,6 @@ function createPosition( ...overrides, }; } - describe('StalkerSeriesViewComponent position compatibility', () => { let fixture: ComponentFixture; 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( 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 { for (let pass = 0; pass < 4; pass++) { fixture.detectChanges(); await Promise.resolve(); } } - async function startWithLoadedEpisode( seriesId = SERIES_A_ID ): Promise { @@ -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; @@ -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 => 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 => { - 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({ diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts index 83d6d991b..275cbffdb 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts @@ -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( diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.ts index d577ddd66..ca5eea600 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.ts @@ -65,6 +65,27 @@ export class PlaybackPositionRuntimeBridgeService { await this.bridge?.dbSavePlaybackPosition?.(playlistId, data); } + async savePlaybackPositionOrThrow( + playlistId: string, + data: PlaybackPositionData + ): Promise { + 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 { + 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 {