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:
4gray authored and GitHub committed 2026-05-25 01:14:11 +03:00
1 parent 520b1628f4
commit 82c725ea5a
15 files changed
+802 -132

No files matched your search

+5
View File
@@ -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,
@@ -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);
}
}
@@ -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;
}
/**
@@ -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
View File
@@ -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',