From a06dd988be1d70cf890b311fdc5d131c4bca8210 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 01:13:01 +0200 Subject: [PATCH] fix(sources): hold Xtream sources through re-import; match secrets in any case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An Xtream refresh released its hold when navigation ended, while the re-import ran on from the route session, so Remove came back during the import's writes. The route session now takes the hold synchronously on NavigationEnd and keeps it until its sync, and the import it awaits, settle; the store also holds the source for every content initialization, retries included. - Secret matching was case-sensitive against encodeURIComponent's upper-case escapes, so `%3a` or a lower-case MAC slipped through. Text and secrets are now compared lower-cased, the text also fully decoded. - The Xtream `· username` label is left out when it is, or holds, the password. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/iptvnator-ui-guidelines.md | 8 +++-- .../lib/source-display/source-display.spec.ts | 30 +++++++++++++++++++ .../src/lib/source-display/source-display.ts | 18 +++++++++-- .../with-content.feature.import-phase.spec.ts | 14 +++++++++ .../stores/features/with-content.feature.ts | 8 +++++ ...am-workspace-route-session.service.spec.ts | 25 ++++++++++++++++ .../xtream-workspace-route-session.service.ts | 21 +++++++++++++ 7 files changed, 119 insertions(+), 5 deletions(-) diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 0b63995f1..3154d474a 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1392,9 +1392,11 @@ in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`, keyboard from Undo: Undo takes the focus and returns it to the trigger (`SourceRemovalOptions.returnFocusTo`). A source cannot be removed while a refresh clears and refills it: every refresh (a Sources row, the - switcher, the Xtream flow, the startup auto-refresh) and every delete - holds the source in the root `SourceActivityService`, which both lists, - the removal service and a second refresh consult. The Sources page shows + switcher, the Xtream flow, the startup auto-refresh), every Xtream import + and every delete holds the source in the root `SourceActivityService`, + which both lists, the removal service and a second refresh consult. The + Xtream route session takes its hold on the refresh's `NavigationEnd`, so + the catalog is never idle between the delete and the re-import. The Sources page shows a switcher refresh as busy. Bulk cleanup keeps its confirmation dialog. - **Side list.** Type and, on desktop, Status (Any / Responding / Not responding) combine and persist (`SourcesViewStateService`); each group diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts index 0b432e309..d75a11155 100644 --- a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts @@ -262,6 +262,18 @@ describe('sourceDisplayUrl', () => { ).toBe('http://xtream.tv:8080 · joe'); }); + it('leaves out an account name that is, or holds, the password', () => { + for (const username of ['hunter2', 'joe-hunter2']) { + expect( + sourceDisplayUrl({ + serverUrl: 'http://xtream.tv:8080/', + username, + password: 'hunter2', + }) + ).toBe('http://xtream.tv:8080'); + } + }); + it('shows the portal URL for Stalker, never the MAC', () => { expect( sourceDisplayUrl({ @@ -281,6 +293,24 @@ describe('sourceDisplayUrl', () => { ).toBe('http://portal.tv'); }); + it('finds a secret whatever the case of its escapes or letters', () => { + for (const portalUrl of [ + 'http://portal.tv/00%3a1A%3a79%3a00%3a00%3a01/c/', + 'http://portal.tv/00:1a:79:00:00:01/c/', + ]) { + expect( + sourceDisplayUrl({ macAddress: '00:1A:79:00:00:01', portalUrl }) + ).toBe('http://portal.tv'); + } + expect( + sourceDisplayName({ + title: 'Box 00:1a:79:00:00:01', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.tv/c/', + }) + ).toBe('portal.tv'); + }); + it('has no URL for a local file or an unparsable address', () => { expect(sourceDisplayUrl({ title: 'local' })).toBeNull(); expect(sourceDisplayUrl({ url: 'not a url' })).toBeNull(); diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.ts index 949598abe..eb3e4fb9d 100644 --- a/libs/playlist/shared/util/src/lib/source-display/source-display.ts +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.ts @@ -202,11 +202,21 @@ function sourceSecrets(playlist: SourceDisplayFields): string[] { return shown; } +/** + * Whether the text shows any of the source's secrets. Escapes may be + * written in either case (`%3a` for `%3A`) and a MAC in either letter case, + * so text and secrets are compared lower-cased, the text as written and + * fully decoded. + */ export function containsSourceSecret( text: string, playlist: SourceDisplayFields ): boolean { - return sourceSecrets(playlist).some((secret) => text.includes(secret)); + const forms = [text, decodeFully(text)].map((form) => form.toLowerCase()); + return sourceSecrets(playlist).some((secret) => { + const needle = secret.toLowerCase(); + return forms.some((form) => form.includes(needle)); + }); } /** @@ -270,7 +280,11 @@ export function sourceDisplayUrl(playlist: SourceDisplayFields): string | null { return null; } const base = `${server.protocol}//${server.host}`; - return playlist.username ? `${base} · ${playlist.username}` : base; + // An account name that is, or holds, the password stays off screen. + const user = playlist.username; + return user && !containsSourceSecret(user, playlist) + ? `${base} · ${user}` + : base; } const stripped = parseUrl( diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.import-phase.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.import-phase.spec.ts index 0b468d510..e35f5e20a 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.import-phase.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.import-phase.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { SourceActivityService } from '@iptvnator/services'; import { PortalStatusType } from '../../xtream-state'; import { createAbortError, @@ -203,6 +204,19 @@ describe('withContent loading-cached phase', () => { ); }); + it('holds the source busy for removal and refresh until the import settles', async () => { + const gate = createDeferred(); + dataSource.getContent.mockImplementation(() => gate.promise); + const activity = TestBed.inject(SourceActivityService); + + const initialization = store.initializeContent(); + expect(activity.isBusy('playlist-1')).toBe(true); + + gate.resolve([]); + await initialization; + expect(activity.isBusy('playlist-1')).toBe(false); + }); + it('still marks the import as running once a remote phase is reported', async () => { dataSource.getCategories.mockImplementation( ( diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 0b8c0874a..3d60a087f 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -23,6 +23,7 @@ import { DbOperationEvent, isDbAbortError, resetHostConnectivityGuard, + SourceActivityService, XtreamPendingRestoreService, XtreamImportStatus, } from '@iptvnator/services'; @@ -244,6 +245,7 @@ export function withContent() { const dataService = inject(DataService); const databaseService = inject(DatabaseService); const pendingRestoreService = inject(XtreamPendingRestoreService); + const sourceActivity = inject(SourceActivityService); const xtreamApiService = inject(XtreamApiService); const importTypes: ContentType[] = ['live', 'vod', 'series']; let activeInitializationPromise: Promise | null = null; @@ -1112,9 +1114,15 @@ export function withContent() { return activeInitializationPromise; } + // Deletion and refreshes wait for the import's writes. + const playlistId = getCredentialsFromStore()?.playlistId; + const release = playlistId + ? sourceActivity.begin([playlistId]) + : undefined; const initializationPromise = executeContentInitialization( ignoreBlockedState ).finally(() => { + release?.(); if (activeInitializationPromise === initializationPromise) { activeInitializationPromise = null; } diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index ce7790828..134201934 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -9,6 +9,7 @@ import { XtreamPlaylistData, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { SourceActivityService } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { XtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service'; @@ -284,6 +285,30 @@ describe('XtreamWorkspaceRouteSession', () => { expect(xtreamStore.initializeContent).toHaveBeenCalled(); }); + it('holds the routed source busy until the import it awaits settles', async () => { + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + const importDone = createDeferred(); + xtreamStore.initializeContent.mockImplementationOnce( + () => importDone.promise + ); + const activity = TestBed.inject(SourceActivityService); + + TestBed.inject(XtreamWorkspaceRouteSession); + // Taken at once, before any await: a refresh's own hold ends when + // its navigation does, and nothing may find the source idle then. + expect(activity.isBusy(PLAYLIST_ID)).toBe(true); + await flushEffects(); + expect(xtreamStore.initializeContent).toHaveBeenCalled(); + expect(activity.isBusy(PLAYLIST_ID)).toBe(true); + + importDone.resolve(); + await flushEffects(); + expect(activity.isBusy(PLAYLIST_ID)).toBe(false); + }); + it('marks the routed section loading before status checks complete during playlist switches', async () => { const portalStatusCheck = createDeferred(); xtreamStore.checkPortalStatus.mockReturnValue( diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 274496307..5fe5a70a0 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -16,6 +16,7 @@ import { XtreamPlaylistData, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { SourceActivityService } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; function normalizeOptionalConnectionValue( @@ -212,6 +213,9 @@ export class XtreamWorkspaceRouteSession { private readonly playlistContext = inject(PlaylistContextFacade); private readonly router = inject(Router); private readonly xtreamStore = inject(XtreamStore); + private readonly activity = inject(SourceActivityService); + /** Sources this sync loop holds busy, released when the loop settles. */ + private readonly heldSources = new Map void>(); private syncInFlight = false; private syncPending = false; @@ -266,6 +270,7 @@ export class XtreamWorkspaceRouteSession { } private scheduleSyncRouteContext(): void { + this.holdRoutedSource(); if (this.syncInFlight) { this.syncPending = true; return; @@ -281,10 +286,26 @@ export class XtreamWorkspaceRouteSession { } while (this.syncPending); } finally { this.syncInFlight = false; + this.heldSources.forEach((release) => release()); + this.heldSources.clear(); } })(); } + /** + * Holds the routed source busy from the moment its route is reached + * until the sync, and the import it awaits, settle. An Xtream refresh + * releases its own hold when its navigation ends, and this one is taken + * on that NavigationEnd, so removal never sees the source idle between + * the catalog delete and the re-import. + */ + private holdRoutedSource(): void { + const playlistId = getXtreamRouteTarget(this.router.url).playlistId; + if (playlistId && !this.heldSources.has(playlistId)) { + this.heldSources.set(playlistId, this.activity.begin([playlistId])); + } + } + private async syncRouteContext(): Promise { const routeContext = this.playlistContext.syncFromUrl(this.router.url); const playlistId =