fix: enhance playlist management by adding user agent and referrer checks, and streamline bootstrap logic

This commit is contained in:
4gray committed 2026-04-06 16:09:56 +02:00
1 parent 062002e91c
commit 58f0d95972
3 files changed
+134 -28

No files matched your search

@@ -29,6 +29,7 @@ export class XtreamCatalogFacadeService
{
private readonly xtreamStore = inject(XtreamStore);
private savedPageBeforeDetail: number | null = null;
private loadedPositionsPlaylistId: string | null = null;
private readonly routeSearchQuery = signal('');
readonly provider = 'xtream' as const;
@@ -135,7 +136,8 @@ export class XtreamCatalogFacadeService
}
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (playlistId) {
if (playlistId && this.loadedPositionsPlaylistId !== playlistId) {
this.loadedPositionsPlaylistId = playlistId;
this.xtreamStore.loadAllPositions(playlistId);
}
@@ -35,6 +35,12 @@ const XTREAM_PLAYLIST: XtreamPlaylistData = {
password: 'secret',
type: 'xtream',
};
const XTREAM_PLAYLIST_WITH_NULL_OPTIONALS = {
...XTREAM_PLAYLIST,
origin: null,
referrer: null,
userAgent: null,
} as XtreamPlaylistData;
async function flushEffects(): Promise<void> {
await Promise.resolve();
@@ -59,6 +65,7 @@ describe('XtreamWorkspaceRouteSession', () => {
const portalStatus = signal<PortalStatusType>('active');
const selectedContentType = signal<'live' | 'vod' | 'series'>('vod');
const selectedCategoryId = signal<number | null>(null);
const isContentInitialized = signal(false);
const contentInitBlockReason =
signal<XtreamContentInitBlockReason | null>(null);
let hasUsableOfflineCache = false;
@@ -78,6 +85,7 @@ describe('XtreamWorkspaceRouteSession', () => {
playlistId.set(nextPlaylistId ?? null);
currentPlaylist.set(null);
selectedContentType.set('vod');
isContentInitialized.set(false);
}),
setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => {
currentPlaylist.set(playlist);
@@ -87,8 +95,11 @@ describe('XtreamWorkspaceRouteSession', () => {
hasUsableOfflineCache: jest.fn().mockImplementation(async () => {
return hasUsableOfflineCache;
}),
isContentInitialized,
contentInitBlockReason,
initializeContent: jest.fn().mockResolvedValue(undefined),
initializeContent: jest.fn().mockImplementation(async () => {
isContentInitialized.set(true);
}),
setSelectedContentType: jest.fn(
(type: 'live' | 'vod' | 'series') => {
selectedContentType.set(type);
@@ -114,11 +125,12 @@ describe('XtreamWorkspaceRouteSession', () => {
routeProvider.set('xtreams');
routePlaylistId.set(PLAYLIST_ID);
activePlaylist.set(ACTIVE_PLAYLIST);
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
currentPlaylist.set(null);
playlistId.set(null);
portalStatus.set('active');
selectedContentType.set('vod');
selectedCategoryId.set(null);
isContentInitialized.set(false);
contentInitBlockReason.set(null);
hasUsableOfflineCache = false;
@@ -198,6 +210,73 @@ describe('XtreamWorkspaceRouteSession', () => {
).toBeGreaterThan(xtreamStore.resetStore.mock.invocationCallOrder[0]);
});
it('does not bootstrap again when the store already holds the active playlist', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
isContentInitialized.set(true);
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(101);
expect(xtreamStore.resetStore).not.toHaveBeenCalled();
expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled();
expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled();
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it('does not treat null and undefined Xtream connection metadata as a playlist change', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
currentPlaylist.set(XTREAM_PLAYLIST_WITH_NULL_OPTIONALS);
playlistId.set(PLAYLIST_ID);
isContentInitialized.set(true);
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.resetStore).not.toHaveBeenCalled();
expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled();
expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled();
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it('does not reinitialize content when switching categories in an initialized playlist', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
xtreamStore.checkPortalStatus.mockImplementation(async () => {
portalStatus.set('active');
return 'active';
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
await flushEffects();
expect(isContentInitialized()).toBe(true);
xtreamStore.resetStore.mockClear();
xtreamStore.fetchXtreamPlaylist.mockClear();
xtreamStore.checkPortalStatus.mockClear();
xtreamStore.initializeContent.mockClear();
xtreamStore.setSelectedContentType.mockClear();
xtreamStore.setSelectedCategory.mockClear();
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/202`;
routerEvents.next(
new NavigationEnd(1, router.url, router.url)
);
await flushEffects();
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(202);
expect(selectedCategoryId()).toBe(202);
expect(xtreamStore.resetStore).not.toHaveBeenCalled();
expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled();
expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled();
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it.each(['expired', 'inactive', 'unavailable'] as const)(
'blocks %s portals before import-driven initialization starts',
async (status) => {
@@ -239,6 +318,8 @@ describe('XtreamWorkspaceRouteSession', () => {
it('rebootstraps the current Xtream playlist when its connection details change', async () => {
activePlaylist.set(UPDATED_ACTIVE_PLAYLIST);
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
xtreamStore.checkPortalStatus.mockImplementation(async () => {
portalStatus.set('active');
return 'active';
@@ -76,13 +76,39 @@ function hasPlaylistConnectionChanges(
return false;
}
const currentUserAgent = currentPlaylist.userAgent ?? null;
const nextUserAgent = nextPlaylist.userAgent ?? null;
const currentReferrer = currentPlaylist.referrer ?? null;
const nextReferrer = nextPlaylist.referrer ?? null;
const currentOrigin = currentPlaylist.origin ?? null;
const nextOrigin = nextPlaylist.origin ?? null;
return (
currentPlaylist.serverUrl !== nextPlaylist.serverUrl ||
currentPlaylist.username !== nextPlaylist.username ||
currentPlaylist.password !== nextPlaylist.password ||
currentPlaylist.userAgent !== nextPlaylist.userAgent ||
currentPlaylist.referrer !== nextPlaylist.referrer ||
currentPlaylist.origin !== nextPlaylist.origin
currentUserAgent !== nextUserAgent ||
currentReferrer !== nextReferrer ||
currentOrigin !== nextOrigin
);
}
function shouldBootstrapXtreamPlaylist(
playlistId: string | null,
routePlaylist: XtreamPlaylistData | null,
storePlaylistId: string | null,
currentPlaylist: XtreamPlaylistData | null
): boolean {
const currentPlaylistUpdateDate = currentPlaylist?.updateDate ?? null;
const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null;
return Boolean(
playlistId &&
routePlaylist &&
(storePlaylistId !== playlistId ||
currentPlaylist?.id !== playlistId ||
currentPlaylistUpdateDate !== routePlaylistUpdateDate ||
hasPlaylistConnectionChanges(currentPlaylist, routePlaylist))
);
}
@@ -117,8 +143,6 @@ export class XtreamWorkspaceRouteSession {
private readonly router = inject(Router);
private readonly xtreamStore = inject(XtreamStore);
private currentPlaylistId: string | null = null;
private currentPlaylistUpdateDate: number | null = null;
private syncInFlight = false;
private syncPending = false;
@@ -186,26 +210,20 @@ export class XtreamWorkspaceRouteSession {
routeContext.provider === 'xtreams'
? toXtreamPlaylistData(this.playlistContext.activePlaylist())
: null;
const storePlaylistId = this.xtreamStore.playlistId();
const currentPlaylist = this.xtreamStore.currentPlaylist();
const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null;
const needsPlaylistBootstrap = Boolean(
playlistId &&
routePlaylist &&
(currentPlaylist?.id !== playlistId ||
this.currentPlaylistUpdateDate !== routePlaylistUpdateDate ||
hasPlaylistConnectionChanges(currentPlaylist, routePlaylist))
const shouldBootstrapPlaylist = shouldBootstrapXtreamPlaylist(
playlistId,
routePlaylist,
storePlaylistId,
currentPlaylist
);
let portalStatus = this.xtreamStore.portalStatus();
let didBootstrapPlaylist = false;
if (
playlistId &&
(this.currentPlaylistId !== playlistId || needsPlaylistBootstrap)
) {
if (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) {
this.xtreamStore.resetStore(playlistId);
this.currentPlaylistId = playlistId;
this.currentPlaylistUpdateDate = routePlaylistUpdateDate;
}
if (playlistId && shouldBootstrapPlaylist) {
this.xtreamStore.resetStore(playlistId);
didBootstrapPlaylist = true;
this.xtreamStore.setCurrentPlaylist(routePlaylist);
@@ -245,7 +263,10 @@ export class XtreamWorkspaceRouteSession {
return;
}
await this.initializeCurrentSectionContent(section);
await this.initializeCurrentSectionContent(
section,
didBootstrapPlaylist
);
}
private syncRouteState(
@@ -267,7 +288,8 @@ export class XtreamWorkspaceRouteSession {
}
private async initializeCurrentSectionContent(
section: PortalRailSection | null
section: PortalRailSection | null,
didBootstrapPlaylist: boolean
): Promise<void> {
const playlist = this.xtreamStore.currentPlaylist();
const playlistId = this.xtreamStore.playlistId();
@@ -277,7 +299,8 @@ export class XtreamWorkspaceRouteSession {
}
if (
isImportDrivenSection(section)
isImportDrivenSection(section) &&
(didBootstrapPlaylist || !this.xtreamStore.isContentInitialized())
) {
await this.xtreamStore.initializeContent();
}