From 9afdad0279782ca3f7abccb85be84ef88b3c4412 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 10:07:04 +0200 Subject: [PATCH] fix(stalker): prefer the live playlist row over a stale favorite snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 on #1364, and mine. `resolveStalker` reads its portal coordinates as `item.stalkerPortalUrl ?? playlist?.portalUrl` — item first. The create_link branch quietly corrected for that afterwards by re-reading `applyOverride(playlist).portalUrl`, so the row won wherever it existed, which is what the comment right above it already promised: "when the row exists it wins over the item's snapshot of the portal URL (a repaired endpoint must beat a stale favorite)". The static branch I added returns before that correction, so it shipped the stale snapshot. Consequences after a playlist edit: a same-host static URL matching the OLD host gets the newly negotiated token and identity headers sent to the previous portal, and a MAC-only edit pairs the new token with the old MAC cookie — precisely the pairing `stalkerIdentityFingerprint` exists to prevent. Both branches now derive the coordinates once, row-first with the repair override applied, and fall back to the item's snapshot only for a playlist that no longer exists — which is the role `buildStalkerPlayback` already documents for it. Mutation-checked: restoring item-first precedence fails the new test alone. Also documents a local-only e2e hazard found while re-running the suite: `mode: 'serial'` orders tests within one project, but chromium/firefox/webkit run the file concurrently against the same mock server, so one project's beforeEach reset can drop a session another is mid-test on — which is what a lone auth-spec failure that passes on rerun actually is. CI never sees it; the Web E2E job runs --project=chromium alone, and that command is clean (22/22). Co-Authored-By: Claude Opus 5 --- apps/web-e2e/src/stalker.e2e.ts | 10 +++++ .../stream-resolver.service.spec.ts | 43 +++++++++++++++++++ .../lib/collection/stream-resolver.service.ts | 17 +++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index a0bff3cf3..07597d02b 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -38,6 +38,16 @@ import { * * Giving each test its own MAC instead would mean inventing a scenario per * test; serializing one file is the cheaper trade. + * + * ACROSS BROWSER PROJECTS this does NOT hold, and it is a local-run hazard + * only. `mode: 'serial'` orders tests within one project; chromium, firefox + * and webkit still run the file concurrently against the SAME mock server, so + * one project's `beforeEach` reset can drop a session another project is + * mid-test on — the auth specs below are the ones that notice, failing as if + * the portal had dropped them. CI never sees it: the Web E2E job runs + * `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local + * all-project run shows a lone auth failure that passes on rerun, this is why; + * `--project=chromium` reproduces CI exactly. */ test.describe.configure({ mode: 'serial' }); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 65fb9f7ca..9114e59a1 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -934,6 +934,49 @@ describe('StreamResolverService', () => { expect(playback.headers?.['Authorization']).toBeUndefined(); }); + it('prefers the edited playlist coordinates over a stale favorite snapshot', async () => { + // A favorite persists the portal URL and MAC it was saved with. After + // the playlist is edited, the session token is negotiated for the NEW + // identity — sending it with the old MAC cookie (or to the old host) + // is the mismatch the identity fingerprint exists to prevent. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: 'https://new.example.com/portal.php', + macAddress: 'AA:BB:CC:00:00:99', + isFullStalkerPortal: false, + } satisfies Partial) + ); + stalkerSession.getCachedToken.mockReturnValue('TOKEN-NEW'); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::93', + name: 'Edited Portal Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '93', + stalkerCmd: 'ffrt3 https://new.example.com/live/93.m3u8', + // Stale snapshot from before the edit. + stalkerPortalUrl: 'https://old.example.com/portal.php', + stalkerMacAddress: '00:11:22:33:44:55', + stalkerItem: { + id: '93', + cmd: 'ffrt3 https://new.example.com/live/93.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(playback.headers?.['Cookie']).toContain('mac=AA:BB:CC:00:00:99'); + expect(playback.headers?.['Cookie']).not.toContain( + 'mac=00:11:22:33:44:55' + ); + // Same host as the edited row ⇒ portal-owned ⇒ credentials attached. + expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-NEW'); + expect(playback.origin).toBe('https://new.example.com'); + }); + it('mints a link for a flagged Stalker favorite', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 1a36a7e1e..9a3301011 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -336,9 +336,22 @@ export class StreamResolverService { const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(item.playlistId) )) as Playlist | undefined; + // The playlist row wins whenever it exists: a repaired endpoint or an + // edited MAC must beat the snapshot a favorite persisted, and the + // session token is negotiated for the ROW's identity — pairing a fresh + // token with a stale MAC cookie is exactly the mismatch the identity + // fingerprint exists to prevent. The item's own coordinates are the + // fallback for a playlist that no longer exists. + const currentPlaylist = playlist + ? this.portalRepair.applyOverride(playlist) + : undefined; const portalUrl = - item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? ''; - const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? ''; + currentPlaylist?.portalUrl ?? + currentPlaylist?.url ?? + item.stalkerPortalUrl ?? + ''; + const macAddress = + currentPlaylist?.macAddress ?? item.stalkerMacAddress ?? ''; // Favorites and Recently Viewed persist the raw catalog row, so the // temporary-link flags travel with the item and this route makes the // same decision the portal views make: an unflagged, directly playable