mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -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
@@ -1167,7 +1167,7 @@ engine` (restart required) or
|
||||
|
||||
- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one.
|
||||
- Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `<base>/portal.php` → `<base>/server/load.php` → `<base>/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `<base>/portal.php`, while canonical-shaped unreachable addresses still abort.
|
||||
- The playlist-info Edit dialog preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; failure releases it without changing the saved or runtime connection. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime session authority may rebase a changed fingerprint only when the persisted row proves that it owns the same playlist ID, so delete/restore and backup merge remain usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
|
||||
- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. It preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; failure releases it without changing the saved or runtime connection. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime session authority may rebase a changed fingerprint only when the persisted row proves that it owns the same playlist ID, so delete/restore and backup merge remain usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
|
||||
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
|
||||
- Explicit Edit advances the repair generation before installing its resolved session. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit.
|
||||
- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them.
|
||||
|
||||
@@ -197,6 +197,10 @@ discovery, since every candidate shares the host.
|
||||
The playlist-info Edit dialog compares URL, MAC, username/password, serial,
|
||||
device IDs and signatures as one connection identity. A metadata-only edit
|
||||
does not run discovery and preserves the stored connection byte-for-byte.
|
||||
Before enabling a Stalker form, the dialog loads the complete persisted
|
||||
playlist row by ID. Electron's startup metadata projection omits payload-only
|
||||
identity fields, so editing that summary directly could otherwise display and
|
||||
then persist empty serial, device ID, signature, or mode values.
|
||||
Changing any connection field disables the form while the same discovery
|
||||
service validates the draft. Auth rejection or an unreachable portal leaves
|
||||
the dialog open and writes nothing. Escape/backdrop closure is disabled for the
|
||||
|
||||
+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