mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps
The VOD multi-source host keys its discovery session to the parental lock version: a lock change drops the discovered sources, retires discoveries and switches in flight, and rediscovers through the worker's new lock state. Stale-index entries of a write still stamping are no longer retried by a concurrent reconcile, which could re-stamp from a store the write had not committed yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ddacab663
commit
4aa1b5a8e6
8 files changed
+121
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean>((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<void> {
|
||||
|
||||
@@ -481,7 +481,7 @@ export class ParentalLockLockStore {
|
||||
*/
|
||||
private markStaleWhileStamping(playlistId: string): void {
|
||||
if (this.runtime.supportsXtreamSqliteDataSource) {
|
||||
this.staleIndex.mark(playlistId);
|
||||
this.staleIndex.markInFlight(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,31 @@ import { computed, signal } from '@angular/core';
|
||||
*/
|
||||
export class ParentalLockStaleIndex {
|
||||
private readonly playlists = new Set<string>();
|
||||
/**
|
||||
* 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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user