fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories

- A backup restore now writes the parental locks last, so a failed Xtream
  merge leaves the playlist's previous locks in place instead of the
  backup's possibly smaller set.
- The Stalker lock dialog snapshots the category list before its lazy
  import and opens only if the route is unchanged, so another portal's
  categories can never be saved under this playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:04:45 +02:00
1 parent eb770b16d6
commit 395dd570d9
5 files changed
+124 -14

No files matched your search

+3 -1
View File
@@ -417,7 +417,9 @@ never runs against the empty fail-closed snapshot.
## Backup
M3U group titles travel verbatim (`normalizeParentalLockGroupTitles`, exact
Locks are restored LAST for each entry, after the Xtream data restore, so a
failed merge leaves the playlist's previous locks in place. M3U group titles
travel verbatim (`normalizeParentalLockGroupTitles`, exact
dedup): locks match `channel.group.title` exactly, so the trimming
`uniqueStrings` used for favorites would weaken a lock on a title with
surrounding whitespace. A backup's lock lists are validated entry by entry
@@ -184,12 +184,15 @@ export class PlaylistBackupService {
this.playlistsService.addPlaylist(nextPlaylist)
);
await this.restoreParentalLocks(targetId, entry, isMerge);
if (entry.portalType === 'xtream') {
await this.restoreXtreamEntry(targetId, entry);
}
// Last: a failed Xtream restore above must leave the
// playlist's previous locks in place rather than commit the
// backup's (possibly smaller) lock set for a failed merge.
await this.restoreParentalLocks(targetId, entry, isMerge);
if (isMerge) {
summary.merged += 1;
} else {
@@ -104,6 +104,31 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
);
});
it('keeps the previous locks when the Xtream part of a merge fails', async () => {
const collaborators = createRestoreCollaborators();
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks,
},
});
collaborators.databaseService.updateCategoryVisibility.mockRejectedValue(
new Error('SQLITE_BUSY')
);
const summary = await service.importBackup(
JSON.stringify(createXtreamManifest([]))
);
expect(summary.failed).toBe(1);
expect(replacePlaylistLocks).not.toHaveBeenCalled();
});
it('never lets a newly created playlist inherit stale locks under a reused id', async () => {
const collaborators = createRestoreCollaborators();
// Empty library: the restore CREATES the playlist (no merge match).
@@ -16,6 +16,7 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { ParentalLockService } from '@iptvnator/services';
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
const translations: Record<string, string> = {
@@ -95,6 +96,10 @@ describe('WorkspaceContextPanelComponent', () => {
reloadCategories: jest.fn(),
};
const stalkerStore = {
getAllCategoriesForSelectedType: jest.fn(() => [
{ category_id: '*', category_name: 'All' },
{ category_id: '1', category_name: 'News' },
]),
getCategoryResource: signal<
Array<{ category_id: string; category_name: string }>
>([]),
@@ -900,4 +905,66 @@ describe('WorkspaceContextPanelComponent', () => {
).toBe('Account disabled');
});
});
describe('Stalker lock dialog', () => {
function enableLock(): void {
const parentalLock = TestBed.inject(ParentalLockService);
Object.defineProperty(parentalLock, 'enabled', {
configurable: true,
value: signal(true),
});
jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true);
}
it('opens with the categories snapshotted before the lazy import', async () => {
enableLock();
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'itv');
fixture.detectChanges();
const component = fixture.componentInstance;
component.loadStalkerLockDialog = jest.fn(
async () => class DialogStub {}
);
await component.openManageStalkerCategories();
expect(dialog.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
data: {
playlistId: 'stalker-1',
contentType: 'itv',
categories: [
{ category_id: '1', category_name: 'News' },
],
},
})
);
});
it('opens nothing when the route changed during the lazy import', async () => {
enableLock();
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'itv');
fixture.detectChanges();
const component = fixture.componentInstance;
component.loadStalkerLockDialog = jest.fn(async () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-2',
});
return class DialogStub {};
});
await component.openManageStalkerCategories();
expect(dialog.open).not.toHaveBeenCalled();
});
});
});
@@ -1,4 +1,5 @@
import {
Type,
Component,
computed,
DestroyRef,
@@ -526,25 +527,37 @@ export class WorkspaceContextPanelComponent {
return;
}
const context = this.context();
const contentType = toParentalLockStalkerCategoryType(this.section());
const section = this.section();
const contentType = toParentalLockStalkerCategoryType(section);
if (!contentType) {
return;
}
const { StalkerCategoryLockDialogComponent } =
await import('@iptvnator/portal/stalker/feature');
this.dialog.open(StalkerCategoryLockDialogComponent, {
data: {
playlistId: context.playlistId,
contentType,
categories: this.stalkerStore
.getAllCategoriesForSelectedType()
.filter((category) => String(category.category_id) !== '*'),
},
// Snapshot before the lazy import: the store follows the CURRENT
// route, so reading it afterwards could hand another portal's
// categories to a dialog that saves them under this playlist.
const categories = this.stalkerStore
.getAllCategoriesForSelectedType()
.filter((category) => String(category.category_id) !== '*');
const dialogComponent = await this.loadStalkerLockDialog();
if (
this.context().playlistId !== context.playlistId ||
this.section() !== section
) {
return;
}
this.dialog.open(dialogComponent, {
data: { playlistId: context.playlistId, contentType, categories },
width: '500px',
maxHeight: '90vh',
});
}
/** The dynamic import; a field so specs can substitute it. */
loadStalkerLockDialog = (): Promise<Type<unknown>> =>
import('@iptvnator/portal/stalker/feature').then(
(module) => module.StalkerCategoryLockDialogComponent
);
async openManageCategories(): Promise<void> {
if (!this.canManageXtreamCategories()) {
return;