fix(playback): keep late Xtream confirmations in their own playlist history

A confirmation that arrives after a switch to another playlist (a slow
MPV/VLC launch) is no longer dropped: the lazy helper saves it to the
captured playlist through the data source, without reloading the store's
recent list, which belongs to the other playlist by then. The store and its
barrel ship in the initial bundle, so the save path stays in the feature
helper; renderer.initialBytes stays under the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-28 07:40:36 +02:00
1 parent 9a2f1fb7e8
commit ce92603981
3 files changed
+73 -11

No files matched your search

@@ -1144,8 +1144,8 @@ reaches history.
the portal `cmd`, never that link. Writers capture the item and its
playlist when they defer, so navigating meanwhile cannot misfile it; an
Xtream write confirmed after a switch to another playlist (only a slow
MPV/VLC launch can) is dropped rather than replacing that playlist's
recent list.
MPV/VLC launch can) is saved to its own playlist without reloading the
store's recent list, which belongs to the other playlist by then.
- Matching: a write deferred with a session key is confirmed only by that
same key — the same URL in two playlists must not let playback in one
(inline, or in MPV/VLC) record a failed attempt in the other. Writes
@@ -5,7 +5,10 @@ import {
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import type {
@@ -29,6 +32,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
/** The bridge callback the service registers at construction. */
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const xtreamDataSource = {
getContentByXtreamId: jest.fn(),
addRecentItem: jest.fn(),
};
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const openResolvedPlayback = jest.fn();
@@ -82,6 +89,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
routeVodId.set(ROUTE_VOD_ID);
positionListener = undefined;
addRecentItem.mockClear();
xtreamDataSource.getContentByXtreamId
.mockReset()
.mockResolvedValue({ id: 77 });
xtreamDataSource.addRecentItem.mockReset().mockResolvedValue(undefined);
closeSession.mockReset().mockResolvedValue(undefined);
openResolvedPlayback
.mockReset()
@@ -106,6 +117,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
.mockReturnValue('https://example.com/route.mkv'),
},
},
{ provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource },
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession, closeSession },
@@ -174,7 +186,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(recentItem.playlist()).toEqual({ id: ROUTE_PLAYLIST });
});
it('drops a confirmation that arrives after a switch to another playlist', async () => {
it('saves a confirmation that arrives after a playlist switch without touching the store', async () => {
// Only a slow MPV/VLC launch can confirm after the page is gone; the
// store's recent list belongs to the other playlist by then.
await service.startResolvedPlayback({
@@ -186,8 +198,19 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['https://example.com/route.mkv'],
});
await Promise.resolve();
expect(addRecentItem).not.toHaveBeenCalled();
expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith(
ROUTE_VOD_ID,
ROUTE_PLAYLIST,
'movie'
);
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
77,
ROUTE_PLAYLIST,
undefined
);
});
it('owns a session launched for the route’s own stream', () => {
@@ -1,6 +1,10 @@
import { inject, signal } from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { inject, Injector, signal } from '@angular/core';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import {
type IXtreamDataSource,
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
export interface XtreamRecentItemRequest {
readonly xtreamId: number | string;
@@ -12,13 +16,13 @@ export interface XtreamRecentItemRequest {
* Records a movie or series as recently viewed once `streamUrl` has really
* played — inline for a couple of seconds, or launched in MPV/VLC — so a
* source that fails straight away never reaches history or the dashboard
* hero.
* hero. The playlist is captured when playback starts, so navigating to
* another one meanwhile cannot misfile the item.
*
* A confirmation that arrives after the user switched to another playlist
* (only a slow MPV/VLC launch can: the inline player goes with the page) is
* dropped: the store's recent list belongs to the other playlist by then,
* and recording it would misfile the item or replace that list. The check
* lives here, off the initial bundle the Xtream store ships in.
* saved to the captured playlist without touching the store, whose recent
* list belongs to the other playlist by then.
*
* Must run in an injection context.
*/
@@ -28,13 +32,48 @@ export function injectXtreamRecentHistory(): (
) => void {
const gate = inject(PlaybackHistoryGate);
const store = inject(XtreamStore);
const injector = inject(Injector);
return (streamUrl, request) => {
const playlist = signal(store.currentPlaylist()).asReadonly();
gate.defer({ streamUrls: [streamUrl] }, () => {
if (store.currentPlaylist()?.id === playlist()?.id) {
const playlistId = playlist()?.id;
if (store.currentPlaylist()?.id === playlistId) {
store.addRecentItem({ ...request, playlist });
} else if (playlistId) {
void saveWithoutListRefresh(
injector.get(XTREAM_DATA_SOURCE),
playlistId,
request
);
}
});
};
}
/**
* 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
* store and its library barrel ship in the initial bundle, this path is
* only reached from lazy detail pages.
*/
async function saveWithoutListRefresh(
dataSource: IXtreamDataSource,
playlistId: string,
{ xtreamId, contentType, backdropUrl }: XtreamRecentItemRequest
): Promise<void> {
const id = Number(xtreamId);
if (!Number.isFinite(id) || id <= 0) {
return;
}
const content = await dataSource.getContentByXtreamId(
id,
playlistId,
contentType
);
const contentId = content?.id ?? (!window.electron ? id : null);
if (contentId != null) {
await dataSource.addRecentItem(contentId, playlistId, backdropUrl);
}
}