diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index aaa94ed98..e9b047d7c 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -144,7 +144,12 @@ lacks the worker filter (`supportsParentalLockSqliteFilter`: preload): the worker would never learn the lock state, so it cannot withhold locked rows itself. The direct worker consumers (`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker -nothing while `withholdsEverything` is true, in either case. +nothing while `withholdsEverything` is true, in either case. The VOD +multi-source host keys its discovery session to the lock version: a lock +change drops the discovered sources, retires discoveries and switches in +flight, and rediscovers through the worker's new lock state. Title-match +results cached by the Actor/Discover routes and dashboard services are not +yet retired on relock (tracked in #1723). The window before the initial read settles is treated the same way (`ParentalLockLockStore.readable` is false until then): settings can report the feature as on before the locks are known — and the workspace route's @@ -409,7 +414,10 @@ by the index alone: failed edit cannot take effect through that re-stamp. For the whole re-stamp window — store changed, stamps not yet landed — the playlist counts as stale, so `readable` is false and a relock inside the window - reloads fail-closed instead of through the old stamps. + reloads fail-closed instead of through the old stamps. Such in-flight + entries are not retried by a reconcile running beside the write (a PIN + prompt or backup calling `ensureReadable()`): it would stamp from a store + the write has not committed yet. Only failed stamps are retryable. - A write that removes a playlist's LAST lock clears the index first and drops the key afterwards: the launch-time reconcile finds playlists only through their key, so an interruption must leave store-with-lock and diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts index ea3e3793e..509a47692 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts @@ -5,6 +5,7 @@ import { VodSourceResolverService, } from '@iptvnator/portal/shared/data-access'; import { + ParentalLockService, SettingsStore, StreamProbeService, VodSourcePinService, @@ -42,6 +43,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => { const playbackLive = signal(false); const playbackStartBlocked = signal(false); const vodAutoFailover = signal(false); + const lockVersion = signal(0); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; const resolver = { resolve: jest.fn() }; @@ -77,6 +79,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => { movie.set(null); playbackStartBlocked.set(false); vodAutoFailover.set(false); + lockVersion.set(0); discovery.isAvailable = true; discovery.discover.mockResolvedValue({ sources: [], @@ -96,6 +99,10 @@ describe('VodMultiSourceHostService — session lifecycle', () => { { provide: VodSourcePinService, useValue: pins }, { provide: StreamProbeService, useValue: probes }, { provide: SettingsStore, useValue: { vodAutoFailover } }, + { + provide: ParentalLockService, + useValue: { version: lockVersion }, + }, ], }); @@ -131,6 +138,33 @@ describe('VodMultiSourceHostService — session lifecycle', () => { expect(discovery.discover).toHaveBeenCalledTimes(2); }); + it('drops the discovered sources and rediscovers when the lock version changes', async () => { + discovery.discover.mockResolvedValue({ + sources: [ALT_TWO], + matchKind: 'title-year', + }); + movie.set(MOVIE_A); + await flushEffects(); + expect(service.sources().some((row) => row.id === ALT_TWO.id)).toBe( + true + ); + + // Lock now: ALT_TWO's category may be locked; the worker answers + // the rediscovery under the new lock state. + discovery.discover.mockResolvedValue({ + sources: [], + matchKind: 'title-year', + }); + lockVersion.set(1); + await flushEffects(); + + expect(discovery.discover).toHaveBeenCalledTimes(2); + expect(service.sources().some((row) => row.id === ALT_TWO.id)).toBe( + false + ); + await expect(service.play(ALT_TWO.id)).resolves.toBe(false); + }); + it('rediscovers the same movie once enrichment describes it better', async () => { movie.set(MOVIE_A); await flushEffects(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts index 41d032d58..f47d33499 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts @@ -5,6 +5,7 @@ import { VodSourceResolverService, } from '@iptvnator/portal/shared/data-access'; import { + ParentalLockService, SettingsStore, StreamProbeService, VodSourcePinService, @@ -124,6 +125,7 @@ export function setupVodMultiSourceHost() { const playbackLive = signal(false); const playbackStartBlocked = signal(false); const vodAutoFailover = signal(false); + const lockVersion = signal(0); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; const resolver = { resolve: jest.fn() }; @@ -135,6 +137,7 @@ export function setupVodMultiSourceHost() { playbackLive, playbackStartBlocked, vodAutoFailover, + lockVersion, startPlayback, discovery, resolver, @@ -150,6 +153,7 @@ export function setupVodMultiSourceHost() { playbackLive.set(false); playbackStartBlocked.set(false); vodAutoFailover.set(false); + lockVersion.set(0); discovery.isAvailable = true; discovery.discover.mockResolvedValue({ sources: [], @@ -169,6 +173,10 @@ export function setupVodMultiSourceHost() { { provide: VodSourcePinService, useValue: pins }, { provide: StreamProbeService, useValue: probes }, { provide: SettingsStore, useValue: { vodAutoFailover } }, + { + provide: ParentalLockService, + useValue: { version: lockVersion }, + }, ], }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts index 1c2a31b94..c89e306ea 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -5,6 +5,7 @@ import { VodSourceResolverService, } from '@iptvnator/portal/shared/data-access'; import { + ParentalLockService, SettingsStore, StreamProbeService, VodSourcePinService, @@ -42,6 +43,7 @@ describe('VodMultiSourceHostService', () => { const playbackLive = signal(false); const playbackStartBlocked = signal(false); const vodAutoFailover = signal(false); + const lockVersion = signal(0); const startPlayback = jest.fn(); const discovery = { isAvailable: true, discover: jest.fn() }; const resolver = { resolve: jest.fn() }; @@ -91,6 +93,7 @@ describe('VodMultiSourceHostService', () => { movie.set(null); playbackStartBlocked.set(false); vodAutoFailover.set(false); + lockVersion.set(0); discovery.isAvailable = true; discovery.discover.mockResolvedValue({ sources: [], @@ -110,6 +113,10 @@ describe('VodMultiSourceHostService', () => { { provide: VodSourcePinService, useValue: pins }, { provide: StreamProbeService, useValue: probes }, { provide: SettingsStore, useValue: { vodAutoFailover } }, + { + provide: ParentalLockService, + useValue: { version: lockVersion }, + }, ], }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index 9001e6046..c787cd0c9 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -14,6 +14,7 @@ import { VodSourceResolverService, } from '@iptvnator/portal/shared/data-access'; import { + ParentalLockService, SettingsStore, StreamProbeService, VodSourcePinService, @@ -97,6 +98,7 @@ export class VodMultiSourceHostService { private readonly probes = inject(StreamProbeService); private readonly probeCache = inject(VodSourceProbeCacheService); private readonly settingsStore = inject(SettingsStore); + private readonly parentalLock = inject(ParentalLockService); /** * At most this many availability checks in flight at once. Each check is @@ -116,6 +118,7 @@ export class VodMultiSourceHostService { /** Bumped by every switch: a slower one must not overwrite a newer. */ private switchToken = 0; private lastMovieKey: string | null = null; + private lastLockVersion: number | null = null; private movieIdentity: string | null = null; /** The row standing for the playlist the route is on. */ private routeSourceId: string | null = null; @@ -170,6 +173,24 @@ export class VodMultiSourceHostService { effect(() => { const movie = bindings.movie(); + // A lock change starts a fresh session: alternatives discovered + // under the previous lock state may sit in a now-locked + // category, and a discovery or switch still in flight must not + // publish or play them. Rediscovery reads through the worker's + // new lock state. + const lockVersion = this.parentalLock.version(); + if ( + this.lastLockVersion !== null && + this.lastLockVersion !== lockVersion + ) { + this.discoveryToken++; + this.sessionToken++; + this.movieIdentity = null; + this.lastMovieKey = null; + this.controller = new VodMultiSourceController(); + this._sources.set([]); + } + this.lastLockVersion = lockVersion; if (!movie) { // Navigating away empties the identity before the next movie's // `load()` runs, so bumping here — not only there — closes the diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts index 0bed76ec8..afde973df 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts @@ -498,6 +498,28 @@ describe('ParentalLockLockStore', () => { expect(storage.writeLocks).toHaveBeenCalledTimes(1); expect(store.readable()).toBe(true); }); + it('does not re-stamp an in-flight write from the uncommitted store', async () => { + await store.load(); + await store.ensureReadable(); + setCategoryLocks.mockClear(); + let finishWrite: (ok: boolean) => void = () => undefined; + storage.writeLocks.mockImplementationOnce( + () => new Promise((resolve) => (finishWrite = resolve)) + ); + + const clearing = store.setXtreamLocks('pl-1', 'live', []); + await waitFor(() => storage.writeLocks.mock.calls.length > 0); + // A PIN prompt or backup reaches ensureReadable() beside the write. + await expect(store.ensureReadable()).resolves.toBe(false); + finishWrite(true); + await expect(clearing).resolves.toBe(true); + + // Only the write's own clear: memory still held [7] while it was in + // flight, and a reconcile stamping that would have re-locked rows + // the store no longer lists. + expect(setCategoryLocks.mock.calls).toEqual([['pl-1', 'live', []]]); + expect(store.readable()).toBe(true); + }); }); async function waitFor(condition: () => boolean): Promise { diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts index e9346d86f..76c210bf8 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -481,7 +481,7 @@ export class ParentalLockLockStore { */ private markStaleWhileStamping(playlistId: string): void { if (this.runtime.supportsXtreamSqliteDataSource) { - this.staleIndex.mark(playlistId); + this.staleIndex.markInFlight(playlistId); } } diff --git a/libs/services/src/lib/parental-lock/parental-lock-stale-index.ts b/libs/services/src/lib/parental-lock/parental-lock-stale-index.ts index 0c3206d4d..966e1a70d 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-stale-index.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-stale-index.ts @@ -8,15 +8,31 @@ import { computed, signal } from '@angular/core'; */ export class ParentalLockStaleIndex { private readonly playlists = new Set(); + /** + * Listed while a queued write is stamping them: stale (not `readable`) + * but not retryable — a reconcile running beside the write would + * re-stamp from a store the write has not committed yet. + */ + private readonly inFlight = new Set(); private readonly count = signal(0); readonly isEmpty = computed(() => this.count() === 0); + /** The retryable entries: not the ones a write is still stamping. */ ids(): string[] { - return [...this.playlists]; + return [...this.playlists].filter((id) => !this.inFlight.has(id)); } + /** Stale and retryable (a failed stamp or rollback). */ mark(playlistId: string): void { + this.inFlight.delete(playlistId); + this.playlists.add(playlistId); + this.count.set(this.playlists.size); + } + + /** Stale while the write in progress stamps it. */ + markInFlight(playlistId: string): void { + this.inFlight.add(playlistId); this.playlists.add(playlistId); this.count.set(this.playlists.size); } @@ -24,6 +40,7 @@ export class ParentalLockStaleIndex { unmark(...playlistIds: string[]): void { for (const playlistId of playlistIds) { this.playlists.delete(playlistId); + this.inFlight.delete(playlistId); } this.count.set(this.playlists.size); }