mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
refactor(playback): add playback position runtime bridge (#1009)
* refactor(playback): add playback position runtime bridge * test(services): avoid angular testing import in playback specs
This commit is contained in:
1 parent
520b1628f4
commit
82c725ea5a
15 files changed
+802
-132
No files matched your search
@@ -79,6 +79,11 @@ storage preload API surface used by `PlaylistsService`, `supportsDownloads`
|
||||
requires the complete downloads preload API surface used by `DownloadsService`,
|
||||
and EPG renderer code should use `EpgRuntimeBridgeService` from
|
||||
`@iptvnator/epg/data-access` instead of calling `window.electron` directly.
|
||||
Playback-position renderer code should use `PlaybackPositionRuntimeBridgeService`
|
||||
from `@iptvnator/services` for Electron SQLite persistence and external-player
|
||||
position update events; `supportsPlaybackPositionStorage` and
|
||||
`supportsPlaybackPositionUpdates` describe those surfaces independently so PWA
|
||||
and partial Electron bridges can degrade without direct preload checks.
|
||||
`supportsEpg` remains the aggregate full EPG capability, while narrower runtime
|
||||
capabilities cover individual EPG surfaces: import/progress, current-program
|
||||
lookup, optional current-program batch reads, optional channel metadata,
|
||||
|
||||
+22
-17
@@ -29,7 +29,11 @@ import {
|
||||
type PlaybackFallbackRequest,
|
||||
VodDetailsComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { DownloadsService, PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
DownloadsService,
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
createStalkerVodItem,
|
||||
PlaybackPositionData,
|
||||
@@ -68,6 +72,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
private readonly catalog = inject(StalkerCatalogFacadeService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
private readonly router = inject(Router);
|
||||
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -153,23 +160,21 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
this.closeInlinePlayer();
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates =
|
||||
window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const currentItem = this.selectedItem();
|
||||
if (
|
||||
data.contentType !== 'vod' ||
|
||||
data.playlistId !== this.catalog.playlist()?.id ||
|
||||
data.contentXtreamId !== Number(currentItem?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedVodPosition.set(data);
|
||||
this.unsubscribePositionUpdates =
|
||||
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const currentItem = this.selectedItem();
|
||||
if (
|
||||
data.contentType !== 'vod' ||
|
||||
data.playlistId !== this.catalog.playlist()?.id ||
|
||||
data.contentXtreamId !== Number(currentItem?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.selectedVodPosition.set(data);
|
||||
}
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerCatalogFacadeService } from './stalker-catalog-facade.service';
|
||||
|
||||
@@ -19,13 +20,34 @@ describe('StalkerCatalogFacadeService', () => {
|
||||
let playbackUpdateHandler:
|
||||
| ((data: PlaybackPositionData) => void)
|
||||
| undefined;
|
||||
let playbackPositionBridge: {
|
||||
onPlaybackPositionUpdate: jest.Mock<
|
||||
(() => void) | undefined,
|
||||
[(data: PlaybackPositionData) => void]
|
||||
>;
|
||||
};
|
||||
let playbackPositions: {
|
||||
savePlaybackPosition: jest.Mock<Promise<void>, [string, PlaybackPositionData]>;
|
||||
getPlaybackPosition: jest.Mock<Promise<PlaybackPositionData | null>, [string, number, 'vod' | 'episode']>;
|
||||
getSeriesPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string, number]>;
|
||||
savePlaybackPosition: jest.Mock<
|
||||
Promise<void>,
|
||||
[string, PlaybackPositionData]
|
||||
>;
|
||||
getPlaybackPosition: jest.Mock<
|
||||
Promise<PlaybackPositionData | null>,
|
||||
[string, number, 'vod' | 'episode']
|
||||
>;
|
||||
getSeriesPlaybackPositions: jest.Mock<
|
||||
Promise<PlaybackPositionData[]>,
|
||||
[string, number]
|
||||
>;
|
||||
getRecentPlaybackPositions?: jest.Mock;
|
||||
getAllPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string]>;
|
||||
clearPlaybackPosition: jest.Mock<Promise<void>, [string, number, 'vod' | 'episode']>;
|
||||
getAllPlaybackPositions: jest.Mock<
|
||||
Promise<PlaybackPositionData[]>,
|
||||
[string]
|
||||
>;
|
||||
clearPlaybackPosition: jest.Mock<
|
||||
Promise<void>,
|
||||
[string, number, 'vod' | 'episode']
|
||||
>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,9 +60,7 @@ describe('StalkerCatalogFacadeService', () => {
|
||||
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
(window as Window & { electron?: typeof window.electron }).electron = {
|
||||
...(window.electron ?? {}),
|
||||
playbackPositionBridge = {
|
||||
onPlaybackPositionUpdate: jest.fn(
|
||||
(handler: (data: PlaybackPositionData) => void) => {
|
||||
playbackUpdateHandler = handler;
|
||||
@@ -55,7 +75,9 @@ describe('StalkerCatalogFacadeService', () => {
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'),
|
||||
selectedContentType: signal<'vod' | 'series' | 'itv'>(
|
||||
'vod'
|
||||
),
|
||||
limit: signal(14),
|
||||
page: signal(0),
|
||||
getSelectedCategory: signal(null),
|
||||
@@ -83,6 +105,10 @@ describe('StalkerCatalogFacadeService', () => {
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: playbackPositions,
|
||||
},
|
||||
{
|
||||
provide: PlaybackPositionRuntimeBridgeService,
|
||||
useValue: playbackPositionBridge,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import {
|
||||
PortalCatalogItemProgress,
|
||||
PortalCatalogPlaylistMeta,
|
||||
@@ -44,6 +45,9 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
> {
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly stalkerPositions = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
@@ -118,8 +122,8 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
void this.loadStalkerPositions(playlistId);
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
const unsubscribe = window.electron.onPlaybackPositionUpdate(
|
||||
const unsubscribe =
|
||||
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
if (
|
||||
!data.playlistId ||
|
||||
@@ -143,9 +147,8 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
}
|
||||
);
|
||||
|
||||
if (typeof unsubscribe === 'function') {
|
||||
this.destroyRef.onDestroy(unsubscribe);
|
||||
}
|
||||
if (unsubscribe) {
|
||||
this.destroyRef.onDestroy(unsubscribe);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-20
@@ -49,7 +49,10 @@ import {
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import {
|
||||
DownloadsService,
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
getStalkerSeriesQuickStartButton,
|
||||
type StalkerQuickStartButton,
|
||||
@@ -81,6 +84,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly router = inject(Router);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
@@ -199,27 +205,24 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
this.activeEpisodeId.set(null);
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates =
|
||||
window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const playlistId =
|
||||
this.stalkerStore.currentPlaylist()?._id;
|
||||
const item = this.displayItem();
|
||||
const seriesId = item ? this.toSeriesId(item.id) : 0;
|
||||
this.unsubscribePositionUpdates =
|
||||
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
const item = this.displayItem();
|
||||
const seriesId = item ? this.toSeriesId(item.id) : 0;
|
||||
|
||||
if (
|
||||
data.contentType !== 'episode' ||
|
||||
data.playlistId !== playlistId ||
|
||||
data.seriesXtreamId !== seriesId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateEpisodePlaybackPosition(data);
|
||||
if (
|
||||
data.contentType !== 'episode' ||
|
||||
data.playlistId !== playlistId ||
|
||||
data.seriesXtreamId !== seriesId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.updateEpisodePlaybackPosition(data);
|
||||
}
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+23
-17
@@ -7,6 +7,7 @@ import {
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XTREAM_DATA_SOURCE,
|
||||
@@ -95,10 +96,8 @@ export function withPlaybackPositions() {
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
return (
|
||||
this.getProgressPercent(
|
||||
contentXtreamId,
|
||||
contentType
|
||||
) >= 90
|
||||
this.getProgressPercent(contentXtreamId, contentType) >=
|
||||
90
|
||||
);
|
||||
},
|
||||
|
||||
@@ -126,11 +125,13 @@ export function withPlaybackPositions() {
|
||||
* Load all playback positions for the playlist (for grid view)
|
||||
*/
|
||||
async loadAllPositions(playlistId: string): Promise<void> {
|
||||
const positions = await dataSource.getAllPlaybackPositions(
|
||||
playlistId
|
||||
);
|
||||
const positions =
|
||||
await dataSource.getAllPlaybackPositions(playlistId);
|
||||
|
||||
const positionsMap = new Map<string, PlaybackPositionData>();
|
||||
const positionsMap = new Map<
|
||||
string,
|
||||
PlaybackPositionData
|
||||
>();
|
||||
const seriesMap = new Map<number, PlaybackPositionData[]>();
|
||||
|
||||
positions.forEach((pos) => {
|
||||
@@ -195,10 +196,11 @@ export function withPlaybackPositions() {
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): Promise<void> {
|
||||
const positions = await dataSource.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
const positions =
|
||||
await dataSource.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
|
||||
const updated = new Map(store.seriesPositions());
|
||||
updated.set(seriesXtreamId, positions);
|
||||
@@ -336,19 +338,23 @@ export function withPlaybackPositions() {
|
||||
}),
|
||||
|
||||
withHooks((store) => {
|
||||
const playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
return {
|
||||
onInit() {
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
unsubscribe = window.electron.onPlaybackPositionUpdate(
|
||||
unsubscribe =
|
||||
playbackPositionBridge.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
if (data.playlistId) {
|
||||
store.savePosition(data.playlistId, data);
|
||||
if (!data.playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.savePosition(data.playlistId, data);
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
onDestroy() {
|
||||
unsubscribe?.();
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
@@ -71,6 +72,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -212,25 +216,23 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
this.activeEpisodeId.set(null);
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates =
|
||||
window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const selectedItem = this.selectedItem();
|
||||
this.unsubscribePositionUpdates =
|
||||
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const selectedItem = this.selectedItem();
|
||||
|
||||
if (
|
||||
data.contentType !== 'episode' ||
|
||||
data.playlistId !== this.currentPlaylistId() ||
|
||||
data.seriesXtreamId !==
|
||||
Number(selectedItem?.series_id ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateEpisodePlaybackPosition(data);
|
||||
if (
|
||||
data.contentType !== 'episode' ||
|
||||
data.playlistId !== this.currentPlaylistId() ||
|
||||
data.seriesXtreamId !==
|
||||
Number(selectedItem?.series_id ?? 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.updateEpisodePlaybackPosition(data);
|
||||
}
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@@ -27,7 +27,11 @@ import {
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
DownloadsService,
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
@@ -64,6 +68,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
@@ -93,23 +100,21 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
return (
|
||||
this.xtreamStore
|
||||
.vodCategories()
|
||||
.find(
|
||||
(category) =>
|
||||
String(
|
||||
this.xtreamStore.vodCategories().find(
|
||||
(category) =>
|
||||
String(
|
||||
(
|
||||
category as XtreamCategory & {
|
||||
id?: string | number;
|
||||
}
|
||||
).category_id ??
|
||||
(
|
||||
category as XtreamCategory & {
|
||||
id?: string | number;
|
||||
}
|
||||
).category_id ??
|
||||
(
|
||||
category as XtreamCategory & {
|
||||
id?: string | number;
|
||||
}
|
||||
).id
|
||||
) === String(categoryId)
|
||||
) ?? null
|
||||
).id
|
||||
) === String(categoryId)
|
||||
) ?? null
|
||||
);
|
||||
});
|
||||
readonly selectedCatalogItem = computed<
|
||||
@@ -309,26 +314,23 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates =
|
||||
window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const playlistId =
|
||||
this.xtreamStore.currentPlaylist()?.id;
|
||||
const vodId = Number(this.route.snapshot.params.vodId);
|
||||
this.unsubscribePositionUpdates =
|
||||
this.playbackPositionBridge.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
const vodId = Number(this.route.snapshot.params.vodId);
|
||||
|
||||
if (
|
||||
data.contentType !== 'vod' ||
|
||||
data.playlistId !== playlistId ||
|
||||
data.contentXtreamId !== vodId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.vodPlaybackPosition.set(data);
|
||||
if (
|
||||
data.contentType !== 'vod' ||
|
||||
data.playlistId !== playlistId ||
|
||||
data.contentXtreamId !== vodId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.vodPlaybackPosition.set(data);
|
||||
}
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './lib/data.service';
|
||||
export * from './lib/database-electron.service';
|
||||
export * from './lib/downloads.service';
|
||||
export * from './lib/playback-position-runtime-bridge.service';
|
||||
export * from './lib/playback-position.service';
|
||||
export * from './lib/playlist-delete-cleanup.token';
|
||||
export * from './lib/playlist-delete-action.service';
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
DestroyableInjector,
|
||||
Injector,
|
||||
runInInjectionContext,
|
||||
} from '@angular/core';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
import { PlaybackPositionRuntimeBridgeService } from './playback-position-runtime-bridge.service';
|
||||
|
||||
describe('PlaybackPositionRuntimeBridgeService', () => {
|
||||
let service: PlaybackPositionRuntimeBridgeService;
|
||||
let injector: DestroyableInjector;
|
||||
let runtimeCapabilities: {
|
||||
supportsPlaybackPositionStorage: boolean;
|
||||
supportsPlaybackPositionUpdates: boolean;
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(() => {
|
||||
runtimeCapabilities = {
|
||||
supportsPlaybackPositionStorage: false,
|
||||
supportsPlaybackPositionUpdates: false,
|
||||
};
|
||||
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: runtimeCapabilities,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = runInInjectionContext(injector, () =>
|
||||
injector.get(PlaybackPositionRuntimeBridgeService)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
injector.destroy();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not call Electron playback-position methods when storage support is unavailable', async () => {
|
||||
const dbSavePlaybackPosition = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
const dbGetPlaybackPosition = jest.fn().mockResolvedValue(null);
|
||||
const dbGetSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||
const dbGetRecentPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||
const dbGetAllPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||
const dbClearAllPlaybackPositions = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
const dbClearPlaybackPosition = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
window.electron = {
|
||||
...window.electron,
|
||||
dbSavePlaybackPosition,
|
||||
dbGetPlaybackPosition,
|
||||
dbGetSeriesPlaybackPositions,
|
||||
dbGetRecentPlaybackPositions,
|
||||
dbGetAllPlaybackPositions,
|
||||
dbClearAllPlaybackPositions,
|
||||
dbClearPlaybackPosition,
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const position = createPosition();
|
||||
|
||||
await expect(
|
||||
service.savePlaybackPosition('playlist-1', position)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.getPlaybackPosition('playlist-1', 100, 'vod')
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
service.getSeriesPlaybackPositions('playlist-1', 200)
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
service.getRecentPlaybackPositions('playlist-1', 5)
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
service.getAllPlaybackPositions('playlist-1')
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
service.clearAllPlaybackPositions('playlist-1')
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.clearPlaybackPosition('playlist-1', 100, 'vod')
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(dbSavePlaybackPosition).not.toHaveBeenCalled();
|
||||
expect(dbGetPlaybackPosition).not.toHaveBeenCalled();
|
||||
expect(dbGetSeriesPlaybackPositions).not.toHaveBeenCalled();
|
||||
expect(dbGetRecentPlaybackPositions).not.toHaveBeenCalled();
|
||||
expect(dbGetAllPlaybackPositions).not.toHaveBeenCalled();
|
||||
expect(dbClearAllPlaybackPositions).not.toHaveBeenCalled();
|
||||
expect(dbClearPlaybackPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates storage calls through the typed Electron bridge when supported', async () => {
|
||||
const position = createPosition();
|
||||
const episodePosition = createPosition({
|
||||
contentXtreamId: 101,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 200,
|
||||
});
|
||||
const dbSavePlaybackPosition = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
const dbGetPlaybackPosition = jest.fn().mockResolvedValue(position);
|
||||
const dbGetSeriesPlaybackPositions = jest
|
||||
.fn()
|
||||
.mockResolvedValue([episodePosition]);
|
||||
const dbGetRecentPlaybackPositions = jest
|
||||
.fn()
|
||||
.mockResolvedValue([position]);
|
||||
const dbGetAllPlaybackPositions = jest
|
||||
.fn()
|
||||
.mockResolvedValue([position, episodePosition]);
|
||||
const dbClearAllPlaybackPositions = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
const dbClearPlaybackPosition = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
window.electron = {
|
||||
...window.electron,
|
||||
dbSavePlaybackPosition,
|
||||
dbGetPlaybackPosition,
|
||||
dbGetSeriesPlaybackPositions,
|
||||
dbGetRecentPlaybackPositions,
|
||||
dbGetAllPlaybackPositions,
|
||||
dbClearAllPlaybackPositions,
|
||||
dbClearPlaybackPosition,
|
||||
} as unknown as typeof window.electron;
|
||||
runtimeCapabilities.supportsPlaybackPositionStorage = true;
|
||||
|
||||
await service.savePlaybackPosition('playlist-1', position);
|
||||
await expect(
|
||||
service.getPlaybackPosition('playlist-1', 100, 'vod')
|
||||
).resolves.toEqual(position);
|
||||
await expect(
|
||||
service.getSeriesPlaybackPositions('playlist-1', 200)
|
||||
).resolves.toEqual([episodePosition]);
|
||||
await expect(
|
||||
service.getRecentPlaybackPositions('playlist-1', 5)
|
||||
).resolves.toEqual([position]);
|
||||
await expect(
|
||||
service.getAllPlaybackPositions('playlist-1')
|
||||
).resolves.toEqual([position, episodePosition]);
|
||||
await service.clearAllPlaybackPositions('playlist-1');
|
||||
await service.clearPlaybackPosition('playlist-1', 100, 'vod');
|
||||
|
||||
expect(dbSavePlaybackPosition).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
position
|
||||
);
|
||||
expect(dbGetPlaybackPosition).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
100,
|
||||
'vod'
|
||||
);
|
||||
expect(dbGetSeriesPlaybackPositions).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
200
|
||||
);
|
||||
expect(dbGetRecentPlaybackPositions).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
5
|
||||
);
|
||||
expect(dbGetAllPlaybackPositions).toHaveBeenCalledWith('playlist-1');
|
||||
expect(dbClearAllPlaybackPositions).toHaveBeenCalledWith('playlist-1');
|
||||
expect(dbClearPlaybackPosition).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
100,
|
||||
'vod'
|
||||
);
|
||||
});
|
||||
|
||||
it('subscribes to playback-position updates only when update events are supported', () => {
|
||||
const unsubscribe = jest.fn();
|
||||
const onPlaybackPositionUpdate = jest.fn(() => unsubscribe);
|
||||
window.electron = {
|
||||
...window.electron,
|
||||
onPlaybackPositionUpdate,
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const callback = jest.fn();
|
||||
|
||||
expect(service.onPlaybackPositionUpdate(callback)).toBeUndefined();
|
||||
expect(onPlaybackPositionUpdate).not.toHaveBeenCalled();
|
||||
|
||||
runtimeCapabilities.supportsPlaybackPositionUpdates = true;
|
||||
|
||||
expect(service.onPlaybackPositionUpdate(callback)).toBe(unsubscribe);
|
||||
expect(onPlaybackPositionUpdate).toHaveBeenCalledWith(callback);
|
||||
});
|
||||
});
|
||||
|
||||
function createPosition(
|
||||
overrides: Partial<PlaybackPositionData> = {}
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId: 100,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 42,
|
||||
durationSeconds: 5400,
|
||||
playlistId: 'playlist-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
type PlaybackPositionContentType = 'vod' | 'episode';
|
||||
|
||||
type PlaybackPositionElectronBridge = Partial<{
|
||||
dbSavePlaybackPosition: (
|
||||
playlistId: string,
|
||||
data: PlaybackPositionData
|
||||
) => Promise<{ success: boolean }>;
|
||||
dbGetPlaybackPosition: (
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: PlaybackPositionContentType
|
||||
) => Promise<PlaybackPositionData | null>;
|
||||
dbGetSeriesPlaybackPositions: (
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
) => Promise<PlaybackPositionData[]>;
|
||||
dbGetRecentPlaybackPositions: (
|
||||
playlistId: string,
|
||||
limit?: number
|
||||
) => Promise<PlaybackPositionData[]>;
|
||||
dbGetAllPlaybackPositions: (
|
||||
playlistId: string
|
||||
) => Promise<PlaybackPositionData[]>;
|
||||
dbClearAllPlaybackPositions: (
|
||||
playlistId: string
|
||||
) => Promise<{ success: boolean }>;
|
||||
dbClearPlaybackPosition: (
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: PlaybackPositionContentType
|
||||
) => Promise<{ success: boolean }>;
|
||||
onPlaybackPositionUpdate: (
|
||||
callback: (data: PlaybackPositionData) => void
|
||||
) => () => void;
|
||||
}>;
|
||||
|
||||
type PlaybackPositionRuntimeWindow = Window & {
|
||||
electron?: PlaybackPositionElectronBridge;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaybackPositionRuntimeBridgeService {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
|
||||
get supportsStorage(): boolean {
|
||||
return this.runtime.supportsPlaybackPositionStorage;
|
||||
}
|
||||
|
||||
get supportsUpdates(): boolean {
|
||||
return this.runtime.supportsPlaybackPositionUpdates;
|
||||
}
|
||||
|
||||
async savePlaybackPosition(
|
||||
playlistId: string,
|
||||
data: PlaybackPositionData
|
||||
): Promise<void> {
|
||||
if (!this.supportsStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.bridge?.dbSavePlaybackPosition?.(playlistId, data);
|
||||
}
|
||||
|
||||
getPlaybackPosition(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: PlaybackPositionContentType
|
||||
): Promise<PlaybackPositionData | null> {
|
||||
if (!this.supportsStorage) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return (
|
||||
this.bridge?.dbGetPlaybackPosition?.(
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
getSeriesPlaybackPositions(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): Promise<PlaybackPositionData[]> {
|
||||
if (!this.supportsStorage) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (
|
||||
this.bridge?.dbGetSeriesPlaybackPositions?.(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
) ?? Promise.resolve([])
|
||||
);
|
||||
}
|
||||
|
||||
getRecentPlaybackPositions(
|
||||
playlistId: string,
|
||||
limit?: number
|
||||
): Promise<PlaybackPositionData[]> {
|
||||
if (!this.supportsStorage) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (
|
||||
this.bridge?.dbGetRecentPlaybackPositions?.(playlistId, limit) ??
|
||||
Promise.resolve([])
|
||||
);
|
||||
}
|
||||
|
||||
getAllPlaybackPositions(
|
||||
playlistId: string
|
||||
): Promise<PlaybackPositionData[]> {
|
||||
if (!this.supportsStorage) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (
|
||||
this.bridge?.dbGetAllPlaybackPositions?.(playlistId) ??
|
||||
Promise.resolve([])
|
||||
);
|
||||
}
|
||||
|
||||
async clearAllPlaybackPositions(playlistId: string): Promise<void> {
|
||||
if (!this.supportsStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.bridge?.dbClearAllPlaybackPositions?.(playlistId);
|
||||
}
|
||||
|
||||
async clearPlaybackPosition(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: PlaybackPositionContentType
|
||||
): Promise<void> {
|
||||
if (!this.supportsStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.bridge?.dbClearPlaybackPosition?.(
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
);
|
||||
}
|
||||
|
||||
onPlaybackPositionUpdate(
|
||||
callback: (data: PlaybackPositionData) => void
|
||||
): (() => void) | undefined {
|
||||
if (!this.supportsUpdates) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.bridge?.onPlaybackPositionUpdate?.(callback);
|
||||
}
|
||||
|
||||
private get bridge(): PlaybackPositionElectronBridge | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (window as PlaybackPositionRuntimeWindow).electron;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
DestroyableInjector,
|
||||
Injector,
|
||||
runInInjectionContext,
|
||||
} from '@angular/core';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackPositionRuntimeBridgeService } from './playback-position-runtime-bridge.service';
|
||||
import { PlaybackPositionService } from './playback-position.service';
|
||||
|
||||
describe('PlaybackPositionService', () => {
|
||||
let service: PlaybackPositionService;
|
||||
let injector: DestroyableInjector;
|
||||
let bridge: jest.Mocked<
|
||||
Pick<
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
| 'savePlaybackPosition'
|
||||
| 'getPlaybackPosition'
|
||||
| 'getSeriesPlaybackPositions'
|
||||
| 'getRecentPlaybackPositions'
|
||||
| 'getAllPlaybackPositions'
|
||||
| 'clearAllPlaybackPositions'
|
||||
| 'clearPlaybackPosition'
|
||||
>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
bridge = {
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
getRecentPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
|
||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
PlaybackPositionService,
|
||||
{
|
||||
provide: PlaybackPositionRuntimeBridgeService,
|
||||
useValue: bridge,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = runInInjectionContext(injector, () =>
|
||||
injector.get(PlaybackPositionService)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
injector.destroy();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('delegates playback-position storage through the runtime bridge', async () => {
|
||||
const position = createPosition();
|
||||
|
||||
bridge.getPlaybackPosition.mockResolvedValue(position);
|
||||
bridge.getSeriesPlaybackPositions.mockResolvedValue([position]);
|
||||
bridge.getRecentPlaybackPositions.mockResolvedValue([position]);
|
||||
bridge.getAllPlaybackPositions.mockResolvedValue([position]);
|
||||
|
||||
await service.savePlaybackPosition('playlist-1', position);
|
||||
await expect(
|
||||
service.getPlaybackPosition('playlist-1', 100, 'vod')
|
||||
).resolves.toEqual(position);
|
||||
await expect(
|
||||
service.getSeriesPlaybackPositions('playlist-1', 200)
|
||||
).resolves.toEqual([position]);
|
||||
await expect(
|
||||
service.getRecentPlaybackPositions('playlist-1', 5)
|
||||
).resolves.toEqual([position]);
|
||||
await expect(
|
||||
service.getAllPlaybackPositions('playlist-1')
|
||||
).resolves.toEqual([position]);
|
||||
await service.clearAllPlaybackPositions('playlist-1');
|
||||
await service.clearPlaybackPosition('playlist-1', 100, 'vod');
|
||||
|
||||
expect(bridge.savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
position
|
||||
);
|
||||
expect(bridge.getPlaybackPosition).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
100,
|
||||
'vod'
|
||||
);
|
||||
expect(bridge.getSeriesPlaybackPositions).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
200
|
||||
);
|
||||
expect(bridge.getRecentPlaybackPositions).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
5
|
||||
);
|
||||
expect(bridge.getAllPlaybackPositions).toHaveBeenCalledWith(
|
||||
'playlist-1'
|
||||
);
|
||||
expect(bridge.clearAllPlaybackPositions).toHaveBeenCalledWith(
|
||||
'playlist-1'
|
||||
);
|
||||
expect(bridge.clearPlaybackPosition).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
100,
|
||||
'vod'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves fallback values when the runtime bridge rejects', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
bridge.savePlaybackPosition.mockRejectedValue(new Error('save failed'));
|
||||
bridge.getPlaybackPosition.mockRejectedValue(new Error('get failed'));
|
||||
bridge.getSeriesPlaybackPositions.mockRejectedValue(
|
||||
new Error('series failed')
|
||||
);
|
||||
bridge.getRecentPlaybackPositions.mockRejectedValue(
|
||||
new Error('recent failed')
|
||||
);
|
||||
bridge.getAllPlaybackPositions.mockRejectedValue(
|
||||
new Error('all failed')
|
||||
);
|
||||
bridge.clearAllPlaybackPositions.mockRejectedValue(
|
||||
new Error('clear all failed')
|
||||
);
|
||||
bridge.clearPlaybackPosition.mockRejectedValue(
|
||||
new Error('clear one failed')
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.savePlaybackPosition('playlist-1', createPosition())
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.getPlaybackPosition('playlist-1', 100, 'vod')
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
service.getSeriesPlaybackPositions('playlist-1', 200)
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
service.getRecentPlaybackPositions('playlist-1', 5)
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
service.getAllPlaybackPositions('playlist-1')
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
service.clearAllPlaybackPositions('playlist-1')
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.clearPlaybackPosition('playlist-1', 100, 'vod')
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function createPosition(
|
||||
overrides: Partial<PlaybackPositionData> = {}
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId: 100,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 42,
|
||||
durationSeconds: 5400,
|
||||
playlistId: 'playlist-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackPositionRuntimeBridgeService } from './playback-position-runtime-bridge.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlaybackPositionService {
|
||||
private readonly playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
|
||||
async savePlaybackPosition(
|
||||
playlistId: string,
|
||||
data: PlaybackPositionData
|
||||
): Promise<void> {
|
||||
try {
|
||||
await window.electron.dbSavePlaybackPosition(playlistId, data);
|
||||
await this.playbackPositionBridge.savePlaybackPosition(
|
||||
playlistId,
|
||||
data
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving playback position:', error);
|
||||
}
|
||||
@@ -22,7 +30,7 @@ export class PlaybackPositionService {
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<PlaybackPositionData | null> {
|
||||
try {
|
||||
return await window.electron.dbGetPlaybackPosition(
|
||||
return await this.playbackPositionBridge.getPlaybackPosition(
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
@@ -38,7 +46,7 @@ export class PlaybackPositionService {
|
||||
seriesXtreamId: number
|
||||
): Promise<PlaybackPositionData[]> {
|
||||
try {
|
||||
return await window.electron.dbGetSeriesPlaybackPositions(
|
||||
return await this.playbackPositionBridge.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
@@ -53,7 +61,7 @@ export class PlaybackPositionService {
|
||||
limit?: number
|
||||
): Promise<PlaybackPositionData[]> {
|
||||
try {
|
||||
return await window.electron.dbGetRecentPlaybackPositions(
|
||||
return await this.playbackPositionBridge.getRecentPlaybackPositions(
|
||||
playlistId,
|
||||
limit
|
||||
);
|
||||
@@ -67,7 +75,9 @@ export class PlaybackPositionService {
|
||||
playlistId: string
|
||||
): Promise<PlaybackPositionData[]> {
|
||||
try {
|
||||
return await window.electron.dbGetAllPlaybackPositions(playlistId);
|
||||
return await this.playbackPositionBridge.getAllPlaybackPositions(
|
||||
playlistId
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting all playback positions:', error);
|
||||
return [];
|
||||
@@ -76,7 +86,9 @@ export class PlaybackPositionService {
|
||||
|
||||
async clearAllPlaybackPositions(playlistId: string): Promise<void> {
|
||||
try {
|
||||
await window.electron.dbClearAllPlaybackPositions(playlistId);
|
||||
await this.playbackPositionBridge.clearAllPlaybackPositions(
|
||||
playlistId
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error clearing all playback positions:', error);
|
||||
}
|
||||
@@ -88,7 +100,7 @@ export class PlaybackPositionService {
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<void> {
|
||||
try {
|
||||
await window.electron.dbClearPlaybackPosition(
|
||||
await this.playbackPositionBridge.clearPlaybackPosition(
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
|
||||
@@ -25,6 +25,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||
expect(service.supportsDownloads).toBe(false);
|
||||
expect(service.supportsPortalActivityStorage).toBe(false);
|
||||
expect(service.supportsPlaybackPositionStorage).toBe(false);
|
||||
expect(service.supportsPlaybackPositionUpdates).toBe(false);
|
||||
expect(service.supportsAppStateStorage).toBe(false);
|
||||
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
|
||||
expect(service.supportsPlaylistRefresh).toBe(false);
|
||||
@@ -90,6 +92,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
dbGetAllPlaybackPositions: jest.fn(),
|
||||
dbClearAllPlaybackPositions: jest.fn(),
|
||||
dbClearPlaybackPosition: jest.fn(),
|
||||
onPlaybackPositionUpdate: jest.fn(),
|
||||
dbDeleteXtreamContent: jest.fn(),
|
||||
dbRestoreXtreamUserData: jest.fn(),
|
||||
downloadsStart: jest.fn(),
|
||||
@@ -142,6 +145,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsXtreamSqliteDataSource).toBe(true);
|
||||
expect(service.supportsDownloads).toBe(true);
|
||||
expect(service.supportsPortalActivityStorage).toBe(true);
|
||||
expect(service.supportsPlaybackPositionStorage).toBe(true);
|
||||
expect(service.supportsPlaybackPositionUpdates).toBe(true);
|
||||
expect(service.supportsAppStateStorage).toBe(true);
|
||||
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
|
||||
expect(service.supportsPlaylistRefresh).toBe(true);
|
||||
@@ -177,6 +182,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||
expect(service.supportsDownloads).toBe(false);
|
||||
expect(service.supportsPortalActivityStorage).toBe(false);
|
||||
expect(service.supportsPlaybackPositionStorage).toBe(false);
|
||||
expect(service.supportsPlaybackPositionUpdates).toBe(false);
|
||||
expect(service.supportsAppStateStorage).toBe(false);
|
||||
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
|
||||
expect(service.supportsPlaylistRefresh).toBe(false);
|
||||
@@ -426,6 +433,39 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsSqlite).toBe(true);
|
||||
});
|
||||
|
||||
it('checks playback-position storage and update bridge capabilities independently', () => {
|
||||
testWindow.electron = {
|
||||
dbSavePlaybackPosition: jest.fn(),
|
||||
dbGetPlaybackPosition: jest.fn(),
|
||||
dbGetSeriesPlaybackPositions: jest.fn(),
|
||||
dbGetRecentPlaybackPositions: jest.fn(),
|
||||
dbGetAllPlaybackPositions: jest.fn(),
|
||||
dbClearAllPlaybackPositions: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new RuntimeCapabilitiesService();
|
||||
|
||||
expect(service.supportsPlaybackPositionStorage).toBe(false);
|
||||
expect(service.supportsPlaybackPositionUpdates).toBe(false);
|
||||
|
||||
testWindow.electron = {
|
||||
...testWindow.electron,
|
||||
dbClearPlaybackPosition: jest.fn(),
|
||||
};
|
||||
|
||||
expect(service.supportsPlaybackPositionStorage).toBe(true);
|
||||
expect(service.supportsPlaybackPositionUpdates).toBe(false);
|
||||
|
||||
testWindow.electron = {
|
||||
...testWindow.electron,
|
||||
onPlaybackPositionUpdate: jest.fn(),
|
||||
};
|
||||
|
||||
expect(service.supportsPlaybackPositionStorage).toBe(true);
|
||||
expect(service.supportsPlaybackPositionUpdates).toBe(true);
|
||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||
});
|
||||
|
||||
it('supports Xtream section navigation in Electron when Xtream API transport is available', () => {
|
||||
testWindow.electron = {
|
||||
xtreamRequest: jest.fn(),
|
||||
|
||||
@@ -8,6 +8,16 @@ type RuntimeWindow = Window & {
|
||||
electron?: RuntimeElectronBridge;
|
||||
};
|
||||
|
||||
const playbackPositionStorageMethods = [
|
||||
'dbSavePlaybackPosition',
|
||||
'dbGetPlaybackPosition',
|
||||
'dbGetSeriesPlaybackPositions',
|
||||
'dbGetRecentPlaybackPositions',
|
||||
'dbGetAllPlaybackPositions',
|
||||
'dbClearAllPlaybackPositions',
|
||||
'dbClearPlaybackPosition',
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RuntimeCapabilitiesService {
|
||||
get environment(): RuntimeEnvironment {
|
||||
@@ -120,18 +130,22 @@ export class RuntimeCapabilitiesService {
|
||||
'dbRemoveRecentItem',
|
||||
'dbClearPlaylistRecentItems',
|
||||
'dbGetContentByXtreamId',
|
||||
'dbSavePlaybackPosition',
|
||||
'dbGetPlaybackPosition',
|
||||
'dbGetSeriesPlaybackPositions',
|
||||
'dbGetRecentPlaybackPositions',
|
||||
'dbGetAllPlaybackPositions',
|
||||
'dbClearAllPlaybackPositions',
|
||||
'dbClearPlaybackPosition',
|
||||
...playbackPositionStorageMethods,
|
||||
'dbDeleteXtreamContent',
|
||||
'dbRestoreXtreamUserData',
|
||||
].every((methodName) => this.hasElectronMethod(methodName));
|
||||
}
|
||||
|
||||
get supportsPlaybackPositionStorage(): boolean {
|
||||
return playbackPositionStorageMethods.every((methodName) =>
|
||||
this.hasElectronMethod(methodName)
|
||||
);
|
||||
}
|
||||
|
||||
get supportsPlaybackPositionUpdates(): boolean {
|
||||
return this.hasElectronMethod('onPlaybackPositionUpdate');
|
||||
}
|
||||
|
||||
get supportsDownloads(): boolean {
|
||||
return [
|
||||
'downloadsStart',
|
||||
|
||||
Reference in new issue
Block a user