fix(settings): count radio playback as lock activity and read the lock store before a restore's stale-id check

- The idle relock no longer interrupts a playing radio station: playing
  <audio> counts as activity, like video.
- A restore retries a failed lock-store read before checking a reused id
  for stale locks, and aborts while the store stays unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 02:31:36 +02:00
1 parent 2161c6a859
commit 34b3d37df0
7 files changed
+76 -4

No files matched your search

@@ -369,3 +369,45 @@ describe('ParentalLockEnforcementService apply serialization', () => {
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledTimes(1);
});
});
describe('ParentalLockEnforcementService busy probe', () => {
it('counts playing audio (the radio player) as activity', () => {
let probe: (() => boolean) | undefined;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: {
version: signal(0),
registerBusyProbe: (fn: () => boolean) => (probe = fn),
},
},
{ provide: XtreamStore, useValue: {} },
{ provide: StalkerStore, useValue: {} },
{ provide: Router, useValue: { url: '/' } },
{
provide: Store,
useValue: { selectSignal: () => signal(null) },
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
const service = TestBed.inject(ParentalLockEnforcementService);
TestBed.runInInjectionContext(() => service.start());
expect(probe?.()).toBe(false);
const audio = document.createElement('audio');
Object.defineProperty(audio, 'paused', { value: false });
Object.defineProperty(audio, 'ended', { value: false });
document.body.appendChild(audio);
try {
expect(probe?.()).toBe(true);
} finally {
audio.remove();
}
});
});
@@ -41,8 +41,11 @@ export class ParentalLockEnforcementService {
return;
}
this.started = true;
this.parentalLock.registerBusyProbe(() =>
this.keepAwake.hasPlayingVideo()
// Playback counts as activity: video through the keep-awake
// tracker, and audio (the radio player) read directly, since the
// keep-awake service deliberately ignores <audio>.
this.parentalLock.registerBusyProbe(
() => this.keepAwake.hasPlayingVideo() || hasPlayingAudio()
);
effect(() => {
const version = this.parentalLock.version();
@@ -294,3 +297,9 @@ export class ParentalLockEnforcementService {
return match ? decodeURIComponent(match[1]) : null;
}
}
function hasPlayingAudio(): boolean {
return Array.from(document.querySelectorAll('audio')).some(
(audio) => !audio.paused && !audio.ended
);
}
+7 -2
View File
@@ -26,7 +26,10 @@ follow in a second PR.
(default 15; `0` = only on restart). The idle timer follows the UNLOCKED
transition: armed the moment a session is unlocked — including the session
that just enabled the feature — and disarmed on lock. Active playback of a built-in web
player counts as interaction, so a film never locks half way.
player counts as interaction, so a film never locks half way — video
through the keep-awake tracker, and audio (the radio player) read from
the playing `<audio>` elements, since the keep-awake service ignores
them.
- This is a child lock, not a security boundary: the PIN hash and the lock
store sit in user-readable app data. The UI says so. There is no PIN
recovery; resetting the app data is the way out.
@@ -357,7 +360,9 @@ deletion has succeeded — the in-memory store empties at once, and a failed
persisted clear is retried on the next store access and overwritten by the
next write. A backup restore that CREATES a playlist (not a merge) starts
that playlist from empty locks, so a reused id can never inherit entries a
failed cleanup left behind.
failed cleanup left behind; the restore retries a failed lock-store read
first (`ensureLocksReadable`) and aborts while it still fails, so that check
never runs against the empty fail-closed snapshot.
## Backup
@@ -437,6 +437,12 @@ export class ParentalLockService {
return this.locks.setM3uLocks(playlistId, groupTitles);
}
/** Retries a failed lock-store read; false while it still fails. */
async ensureLocksReadable(): Promise<boolean> {
await this.initialize();
return this.locks.ensureReadable();
}
/** Playlist deletion: its locks leave the store. */
async removePlaylistLocks(playlistId: string): Promise<boolean> {
await this.initialize();
@@ -123,6 +123,7 @@ export function createPlaylistBackupService(
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
@@ -1117,6 +1117,14 @@ export class PlaylistBackupService {
entry: PlaylistBackupEntry,
isMerge: boolean
): Promise<void> {
// The stale-id check below and the merge base both read the store; an
// empty fail-closed snapshot would let a reused id keep a deleted
// playlist's locks once a later read recovers them.
if (!(await this.parentalLock.ensureLocksReadable())) {
throw new PlaylistBackupError(
`Restoring the parental locks for "${playlistId}" failed: the lock store could not be read.`
);
}
// A playlist created by this restore owns no locks yet: whatever the
// store holds under a reused id belongs to a deleted playlist (a
// cleanup that failed), and must not be inherited.
@@ -114,6 +114,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
// A failed cleanup left locks under the id the restore reuses.
locksFor: jest.fn(() => ({
xtream: [{ categoryType: 'live', xtreamId: 1 }],