fix(playback): per-channel M3U history attempts, capability-based Xtream fallback

- M3U: the recently-viewed dedupe key now includes the channel id, so a
  second row of the same URL defers its own write (its session key) and
  is recorded when it plays after the first row failed.
- Xtream late write: key uncached content by Xtream id per
  supportsXtreamSqliteDataSource (the data-source factory's contract), not
  by a generic Electron bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-28 08:35:52 +02:00
1 parent ba1b7a8d73
commit eaa577e858
4 files changed
+73 -6

No files matched your search

@@ -229,6 +229,26 @@ describe('VideoPlayerComponent — recently viewed history', () => {
);
});
it('records a second row of the same URL that plays after the first failed', () => {
// One stream listed twice in a playlist, under two channel ids.
select();
select({
...sampleChannel,
id: 'channel-1-copy',
name: 'Sample TV HD',
});
gate.confirm({
sessionKey: component.playbackSessionKey(),
streamUrls: [sampleChannel.url],
});
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith(
'playlist-1',
expect.objectContaining({ title: 'Sample TV HD' })
);
});
it('records a radio station once the audio player confirms it', () => {
const radio = { ...sampleChannel, radio: 'true' };
select(radio);
@@ -850,7 +850,9 @@ export class VideoPlayerComponent
return;
}
const nextKey = `${playlistId}::${activeChannel.url}`;
// Channel identity too: two rows of one URL are separate
// attempts, confirmed under their own session keys.
const nextKey = `${playlistId}::${activeChannel.id}::${activeChannel.url}`;
if (this.lastRecordedRecentKey === nextKey) {
return;
}
@@ -9,7 +9,10 @@ import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
PlaybackPositionRuntimeBridgeService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import type {
PlaybackPositionData,
@@ -213,6 +216,42 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
);
});
it.each([
['the API-only data source', false, 1],
['the SQLite data source', true, 0],
])(
'keys a late, uncached write by Xtream id only for %s',
async (_label, sqlite: boolean, saves: number) => {
// A partial Electron bridge still selects the API-only source.
jest.spyOn(
TestBed.inject(RuntimeCapabilitiesService),
'supportsXtreamSqliteDataSource',
'get'
).mockReturnValue(sqlite);
xtreamDataSource.getContentByXtreamId.mockResolvedValue(null);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/route.mkv',
title: 'Slow external launch',
});
currentPlaylist.set({ id: 'playlist-switched-meanwhile' });
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['https://example.com/route.mkv'],
});
await Promise.resolve();
await Promise.resolve();
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledTimes(saves);
if (saves) {
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
ROUTE_VOD_ID,
ROUTE_PLAYLIST,
undefined
);
}
}
);
it('owns a session launched for the route’s own stream', () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
@@ -1,5 +1,6 @@
import { inject, Injector, signal } from '@angular/core';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
type IXtreamDataSource,
XTREAM_DATA_SOURCE,
@@ -43,6 +44,10 @@ export function injectXtreamRecentHistory(): (
} else if (playlistId) {
void saveWithoutListRefresh(
injector.get(XTREAM_DATA_SOURCE),
// The data source factory picks SQLite by this contract,
// not by a generic Electron bridge.
!injector.get(RuntimeCapabilitiesService)
.supportsXtreamSqliteDataSource,
playlistId,
request
);
@@ -52,14 +57,15 @@ export function injectXtreamRecentHistory(): (
}
/**
* The save half of `withRecentItems.addRecentItem` (same content lookup and
* PWA fallback to the Xtream id for cold content), without reloading the
* store's recent list. Kept here rather than shared with the store: the
* The save half of `withRecentItems.addRecentItem` (same content lookup;
* the API-only data source keys cold content by its Xtream id), without
* reloading the store's recent list. Kept here rather than shared with the store: the
* store and its library barrel ship in the initial bundle, this path is
* only reached from lazy detail pages.
*/
async function saveWithoutListRefresh(
dataSource: IXtreamDataSource,
keysByXtreamId: boolean,
playlistId: string,
{ xtreamId, contentType, backdropUrl }: XtreamRecentItemRequest
): Promise<void> {
@@ -72,7 +78,7 @@ async function saveWithoutListRefresh(
playlistId,
contentType
);
const contentId = content?.id ?? (!window.electron ? id : null);
const contentId = content?.id ?? (keysByXtreamId ? id : null);
if (contentId != null) {
await dataSource.addRecentItem(contentId, playlistId, backdropUrl);
}