mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(playback): expose strict position persistence
This commit is contained in:
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 =
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
(episodeDownloadRequested)="downloadEpisode($event)"
|
||||
(playbackToggleRequested)="
|
||||
handlePlaybackToggleRequested($event)
|
||||
handlePlaybackToggleRequestedFromUi($event)
|
||||
"
|
||||
#seasonContainer
|
||||
/>
|
||||
|
||||
+36
-2
@@ -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,
|
||||
|
||||
+43
-26
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user