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 =