mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(stalker): hydrate playlist identity before edit
This commit is contained in:
1 parent
7c8b94a9ed
commit
e1f0031706
5 files changed
+138
-15
No files matched your search
+5
-3
@@ -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()
|
||||
"
|
||||
>
|
||||
{{
|
||||
|
||||
+68
-9
@@ -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;
|
||||
|
||||
+60
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user