From 82c725ea5a36fe606462c7643adf9e324ba77f58 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 25 May 2026 01:14:11 +0300 Subject: [PATCH] refactor(playback): add playback position runtime bridge (#1009) * refactor(playback): add playback position runtime bridge * test(services): avoid angular testing import in playback specs --- docs/architecture/pwa-self-hosted.md | 5 + .../stalker-catalog-detail.component.ts | 39 ++-- .../stalker-catalog-facade.service.spec.ts | 44 +++- .../src/lib/stalker-catalog-facade.service.ts | 13 +- .../stalker-series-view.component.ts | 43 ++-- .../with-playback-positions.feature.ts | 40 ++-- .../serial-details.component.ts | 36 +-- .../vod-details-route.component.ts | 66 +++--- libs/services/src/index.ts | 1 + ...ck-position-runtime-bridge.service.spec.ts | 215 ++++++++++++++++++ ...layback-position-runtime-bridge.service.ts | 170 ++++++++++++++ .../src/lib/playback-position.service.spec.ts | 166 ++++++++++++++ .../src/lib/playback-position.service.ts | 28 ++- .../lib/runtime-capabilities.service.spec.ts | 40 ++++ .../src/lib/runtime-capabilities.service.ts | 28 ++- 15 files changed, 802 insertions(+), 132 deletions(-) create mode 100644 libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts create mode 100644 libs/services/src/lib/playback-position-runtime-bridge.service.ts create mode 100644 libs/services/src/lib/playback-position.service.spec.ts diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 2288fa1d3..822d96f29 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 1fcb7b1f8..40905051e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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 { diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 60eb1594f..49d710289 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -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, [string, PlaybackPositionData]>; - getPlaybackPosition: jest.Mock, [string, number, 'vod' | 'episode']>; - getSeriesPlaybackPositions: jest.Mock, [string, number]>; + savePlaybackPosition: jest.Mock< + Promise, + [string, PlaybackPositionData] + >; + getPlaybackPosition: jest.Mock< + Promise, + [string, number, 'vod' | 'episode'] + >; + getSeriesPlaybackPositions: jest.Mock< + Promise, + [string, number] + >; getRecentPlaybackPositions?: jest.Mock; - getAllPlaybackPositions: jest.Mock, [string]>; - clearPlaybackPosition: jest.Mock, [string, number, 'vod' | 'episode']>; + getAllPlaybackPositions: jest.Mock< + Promise, + [string] + >; + clearPlaybackPosition: jest.Mock< + Promise, + [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, + }, ], }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 9618de29b..d7e0ec52d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -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 @@ -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); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 2b6a8276f..bdba58275 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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; } /** diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts index d20c5e4e4..a1d39f2ac 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts @@ -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 { - const positions = await dataSource.getAllPlaybackPositions( - playlistId - ); + const positions = + await dataSource.getAllPlaybackPositions(playlistId); - const positionsMap = new Map(); + const positionsMap = new Map< + string, + PlaybackPositionData + >(); const seriesMap = new Map(); positions.forEach((pos) => { @@ -195,10 +196,11 @@ export function withPlaybackPositions() { playlistId: string, seriesXtreamId: number ): Promise { - 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?.(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index e7890f92c..f518ef24b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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 { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 27c728178..5a436853d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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 { diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 8270af674..5030bf1e4 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts new file mode 100644 index 000000000..83d6d991b --- /dev/null +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts @@ -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 { + return { + contentXtreamId: 100, + contentType: 'vod', + positionSeconds: 42, + durationSeconds: 5400, + playlistId: 'playlist-1', + ...overrides, + }; +} diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.ts new file mode 100644 index 000000000..d577ddd66 --- /dev/null +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.ts @@ -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; + dbGetSeriesPlaybackPositions: ( + playlistId: string, + seriesXtreamId: number + ) => Promise; + dbGetRecentPlaybackPositions: ( + playlistId: string, + limit?: number + ) => Promise; + dbGetAllPlaybackPositions: ( + playlistId: string + ) => Promise; + 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 { + if (!this.supportsStorage) { + return; + } + + await this.bridge?.dbSavePlaybackPosition?.(playlistId, data); + } + + getPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: PlaybackPositionContentType + ): Promise { + if (!this.supportsStorage) { + return Promise.resolve(null); + } + + return ( + this.bridge?.dbGetPlaybackPosition?.( + playlistId, + contentXtreamId, + contentType + ) ?? Promise.resolve(null) + ); + } + + getSeriesPlaybackPositions( + playlistId: string, + seriesXtreamId: number + ): Promise { + if (!this.supportsStorage) { + return Promise.resolve([]); + } + + return ( + this.bridge?.dbGetSeriesPlaybackPositions?.( + playlistId, + seriesXtreamId + ) ?? Promise.resolve([]) + ); + } + + getRecentPlaybackPositions( + playlistId: string, + limit?: number + ): Promise { + if (!this.supportsStorage) { + return Promise.resolve([]); + } + + return ( + this.bridge?.dbGetRecentPlaybackPositions?.(playlistId, limit) ?? + Promise.resolve([]) + ); + } + + getAllPlaybackPositions( + playlistId: string + ): Promise { + if (!this.supportsStorage) { + return Promise.resolve([]); + } + + return ( + this.bridge?.dbGetAllPlaybackPositions?.(playlistId) ?? + Promise.resolve([]) + ); + } + + async clearAllPlaybackPositions(playlistId: string): Promise { + if (!this.supportsStorage) { + return; + } + + await this.bridge?.dbClearAllPlaybackPositions?.(playlistId); + } + + async clearPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: PlaybackPositionContentType + ): Promise { + 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; + } +} diff --git a/libs/services/src/lib/playback-position.service.spec.ts b/libs/services/src/lib/playback-position.service.spec.ts new file mode 100644 index 000000000..50be4e26c --- /dev/null +++ b/libs/services/src/lib/playback-position.service.spec.ts @@ -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 { + return { + contentXtreamId: 100, + contentType: 'vod', + positionSeconds: 42, + durationSeconds: 5400, + playlistId: 'playlist-1', + ...overrides, + }; +} diff --git a/libs/services/src/lib/playback-position.service.ts b/libs/services/src/lib/playback-position.service.ts index 97252e074..c70a17797 100644 --- a/libs/services/src/lib/playback-position.service.ts +++ b/libs/services/src/lib/playback-position.service.ts @@ -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 { 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 { try { - return await window.electron.dbGetPlaybackPosition( + return await this.playbackPositionBridge.getPlaybackPosition( playlistId, contentXtreamId, contentType @@ -38,7 +46,7 @@ export class PlaybackPositionService { seriesXtreamId: number ): Promise { try { - return await window.electron.dbGetSeriesPlaybackPositions( + return await this.playbackPositionBridge.getSeriesPlaybackPositions( playlistId, seriesXtreamId ); @@ -53,7 +61,7 @@ export class PlaybackPositionService { limit?: number ): Promise { try { - return await window.electron.dbGetRecentPlaybackPositions( + return await this.playbackPositionBridge.getRecentPlaybackPositions( playlistId, limit ); @@ -67,7 +75,9 @@ export class PlaybackPositionService { playlistId: string ): Promise { 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 { 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 { try { - await window.electron.dbClearPlaybackPosition( + await this.playbackPositionBridge.clearPlaybackPosition( playlistId, contentXtreamId, contentType diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 160815da6..f0e5516e0 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -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(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 4f683059a..f3df8bc25 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -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',