fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 15:11:18 +02:00
1 parent 4aa1b5a8e6
commit 067f920833
8 files changed
+267 -62

No files matched your search

@@ -927,6 +927,38 @@ describe('WorkspaceContextPanelComponent', () => {
).not.toBeNull();
});
it('opens no Xtream category dialog when the route changed while the PIN was asked', async () => {
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'xtream-1',
});
fixture.componentRef.setInput('section', 'vod');
fixture.detectChanges();
const component = fixture.componentInstance;
Object.defineProperty(component, 'canManageXtreamCategories', {
configurable: true,
value: () => true,
});
const parentalLock = TestBed.inject(ParentalLockService);
jest.spyOn(parentalLock, 'requestUnlock').mockImplementation(
async () => {
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'xtream-2',
});
return true;
}
);
component.loadXtreamCategoryDialog = jest.fn(
async () => class DialogStub {}
);
await component.openManageCategories();
expect(component.loadXtreamCategoryDialog).not.toHaveBeenCalled();
expect(dialog.open).not.toHaveBeenCalled();
});
describe('Stalker lock dialog', () => {
function enableLock(): void {
const parentalLock = TestBed.inject(ParentalLockService);
@@ -572,51 +572,59 @@ export class WorkspaceContextPanelComponent {
(module) => module.StalkerCategoryLockDialogComponent
);
/** The dynamic import; a field so specs can substitute it. */
loadXtreamCategoryDialog = (): Promise<Type<unknown>> =>
import('@iptvnator/portal/xtream/feature').then(
(module) => module.CategoryManagementDialogComponent
);
async openManageCategories(): Promise<void> {
if (!this.canManageXtreamCategories()) {
return;
}
// Hidden AND locked categories are listed there by name, so the
// dialog itself sits behind the PIN while the lock is on.
if (!(await this.parentalLock.requestUnlock())) {
return;
}
// Captured before any await (the PIN prompt and the dialog load
// lazily) and re-checked after each: the dialog saves visibility and
// locks for the playlist it was opened for, which must still be the
// one on screen.
const context = this.context();
const section = this.section();
const unchanged = (): boolean =>
this.context().playlistId === context.playlistId &&
this.context().provider === context.provider &&
this.section() === section &&
this.canManageXtreamCategories();
const contentType =
section === 'series'
? 'series'
: section === 'live'
? 'live'
: 'vod';
void import('@iptvnator/portal/xtream/feature').then(
({ CategoryManagementDialogComponent }) => {
const dialogRef = this.dialog.open(
CategoryManagementDialogComponent,
{
data: {
playlistId: context.playlistId,
contentType,
itemCounts:
this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '90vh',
}
);
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
);
// Hidden AND locked categories are listed there by name, so the
// dialog itself sits behind the PIN while the lock is on.
if (!(await this.parentalLock.requestUnlock()) || !unchanged()) {
return;
}
const dialogComponent = await this.loadXtreamCategoryDialog();
if (!unchanged()) {
return;
}
const dialogRef = this.dialog.open(dialogComponent, {
data: {
playlistId: context.playlistId,
contentType,
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '90vh',
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
hideCategories(): void {