mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
ba1b7a8d73
commit
eaa577e858
4 files changed
+73
-6
No files matched your search
+20
@@ -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;
|
||||
}
|
||||
|
||||
+40
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user