fix(stalker): hydrate playlist identity before edit

This commit is contained in:
4gray committed 2026-08-09 10:53:58 +02:00
1 parent 7c8b94a9ed
commit e1f0031706
5 files changed
+138 -15

No files matched your search

@@ -7,8 +7,8 @@
</h2>
<fieldset
class="playlist-info-fields"
[disabled]="isSaving()"
[attr.aria-busy]="isSaving()"
[disabled]="isSaving() || isHydratingStalkerPlaylist()"
[attr.aria-busy]="isSaving() || isHydratingStalkerPlaylist()"
>
<mat-dialog-content class="mat-typography">
<mat-form-field class="w-full">
@@ -364,7 +364,9 @@
[disabled]="
!playlistDetails.valid ||
playlistDetails.pristine ||
isSaving()
isSaving() ||
isHydratingStalkerPlaylist() ||
stalkerPlaylistHydrationFailed()
"
>
{{
@@ -3,7 +3,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
@@ -23,6 +23,7 @@ describe('PlaylistInfoComponent', () => {
let component: PlaylistInfoComponent;
let fixture: ComponentFixture<PlaylistInfoComponent>;
let playlistsService: {
getPlaylistById: jest.Mock;
getRawPlaylistById: jest.Mock;
};
let databaseService: {
@@ -70,6 +71,7 @@ describe('PlaylistInfoComponent', () => {
beforeEach(async () => {
playlistsService = {
getPlaylistById: jest.fn(),
getRawPlaylistById: jest.fn(() => of('#EXTM3U\n')),
};
databaseService = {
@@ -592,20 +594,76 @@ describe('PlaylistInfoComponent', () => {
function createStalkerComponent(
overrides: Partial<Playlist> = {}
): void {
const stalkerPlaylist = {
...playlist,
url: undefined,
portalUrl: 'https://portal.example.com/c',
macAddress: '00:1a:79:aa:bb:cc',
isFullStalkerPortal: true,
...overrides,
} as Playlist & { id: string };
playlistsService.getPlaylistById.mockReturnValue(
of(stalkerPlaylist)
);
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
url: undefined,
portalUrl: 'https://portal.example.com/c',
macAddress: '00:1a:79:aa:bb:cc',
isFullStalkerPortal: true,
...overrides,
} as Playlist & { id: string },
useValue: stalkerPlaylist,
});
createComponent();
fixture.detectChanges();
}
it('hydrates the complete stored row before editing a summarized Stalker playlist', async () => {
const summary = {
...playlist,
url: undefined,
portalUrl: 'https://portal.example.com/c',
macAddress: '00:1a:79:aa:bb:cc',
} as Playlist & { id: string };
const storedPlaylist = {
...summary,
isFullStalkerPortal: true,
stalkerSerialNumber: 'STORED-SERIAL',
stalkerDeviceId1: 'STORED-DEVICE-1',
stalkerDeviceId2: 'STORED-DEVICE-2',
stalkerSignature1: 'STORED-SIGNATURE-1',
stalkerSignature2: 'STORED-SIGNATURE-2',
};
const storedPlaylist$ = new Subject<Playlist>();
playlistsService.getPlaylistById.mockReturnValue(storedPlaylist$);
TestBed.overrideProvider(MAT_DIALOG_DATA, { useValue: summary });
createComponent();
fixture.detectChanges();
expect(component.isHydratingStalkerPlaylist()).toBe(true);
storedPlaylist$.next(storedPlaylist);
storedPlaylist$.complete();
await fixture.whenStable();
component.playlistDetails
.get('portalUrl')
?.setValue('https://new.example.com');
await component.saveChanges(
component.playlistDetails.getRawValue() as PlaylistMeta
);
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
'playlist-1'
);
expect(
stalkerConnectionEditor.resolveConnection
).toHaveBeenCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com',
stalkerSerialNumber: 'STORED-SERIAL',
stalkerDeviceId1: 'STORED-DEVICE-1',
stalkerDeviceId2: 'STORED-DEVICE-2',
stalkerSignature1: 'STORED-SIGNATURE-1',
stalkerSignature2: 'STORED-SIGNATURE-2',
})
);
});
it('canonicalizes an edited MAC on blur', () => {
createStalkerComponent();
const control = component.playlistDetails.get('macAddress');
@@ -836,6 +894,7 @@ describe('PlaylistInfoComponent', () => {
it('ignores a second Save while discovery is pending', async () => {
createStalkerComponent();
await fixture.whenStable();
let resolveDiscovery:
| ((value: { status: 'unreachable'; message: string }) => void)
| undefined;
@@ -192,6 +192,9 @@ export class PlaylistInfoComponent {
});
public playlistData = inject<Playlist & { id: string }>(MAT_DIALOG_DATA);
readonly isSaving = signal(false);
readonly isHydratingStalkerPlaylist = signal(false);
readonly stalkerPlaylistHydrationFailed = signal(false);
private readonly stalkerPlaylistHydration: Promise<void>;
get isDesktop(): boolean {
return this.runtime.supportsDesktopFileSave;
@@ -295,6 +298,13 @@ export class PlaylistInfoComponent {
constructor() {
this.playlist = this.playlistData;
this.createForm();
if (this.playlist.portalUrl) {
this.isHydratingStalkerPlaylist.set(true);
this.stalkerPlaylistHydration =
this.hydrateCompleteStalkerPlaylist();
} else {
this.stalkerPlaylistHydration = Promise.resolve();
}
}
/**
@@ -360,7 +370,7 @@ export class PlaylistInfoComponent {
}
async saveChanges(playlist: PlaylistMeta): Promise<void> {
if (this.isSaving() || this.playlistDetails.invalid) {
if (this.isSaving()) {
return;
}
@@ -369,10 +379,22 @@ export class PlaylistInfoComponent {
this.dialogRef.disableClose = true;
}
try {
let submittedPlaylist = playlist;
if (this.isHydratingStalkerPlaylist()) {
await this.stalkerPlaylistHydration;
submittedPlaylist = this.playlistDetails.value as PlaylistMeta;
}
if (
this.stalkerPlaylistHydrationFailed() ||
this.playlistDetails.invalid
) {
return;
}
let resolvedStalkerConnection = false;
let normalizedPlaylist: PlaylistMetaUpdate =
this.normalizeStalkerPlaylistMeta(
this.normalizeXtreamPlaylistMeta(playlist)
this.normalizeXtreamPlaylistMeta(submittedPlaylist)
);
if (this.playlist.portalUrl) {
if (
@@ -456,6 +478,42 @@ export class PlaylistInfoComponent {
}
}
/**
* Electron's startup metadata projection deliberately excludes the
* payload-only Stalker identity and session fields. Editing that summary
* directly would render the identity controls empty and could clear a
* portal-pinned serial/device identity on the next discovery. Hydrate the
* authoritative row before enabling the form in every runtime so Edit
* always starts from the same persisted connection that playback uses.
*/
private async hydrateCompleteStalkerPlaylist(): Promise<void> {
try {
const persistedPlaylist = await firstValueFrom(
this.playlistsService.getPlaylistById(this.playlist._id)
);
if (!persistedPlaylist) {
throw new Error('Stored Stalker playlist was not found');
}
this.playlist = {
...this.playlistData,
...persistedPlaylist,
id: this.playlistData.id ?? persistedPlaylist._id,
};
this.createForm();
} catch (error) {
console.error('Failed to load complete Stalker playlist:', error);
this.stalkerPlaylistHydrationFailed.set(true);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
} finally {
this.isHydratingStalkerPlaylist.set(false);
}
}
/**
* Canonicalizes the MAC on the submit path as well as on blur. Pressing
* Enter inside the field submits the dialog without the field losing