mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(sources): hold Xtream sources through re-import; match secrets in any case
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb854a9621
commit
a06dd988be
7 files changed
+119
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
+14
@@ -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<unknown[]>();
|
||||
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(
|
||||
(
|
||||
|
||||
@@ -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<void> | 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;
|
||||
}
|
||||
|
||||
@@ -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<void>();
|
||||
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<PortalStatusType>();
|
||||
xtreamStore.checkPortalStatus.mockReturnValue(
|
||||
|
||||
@@ -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<string, () => 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<void> {
|
||||
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
|
||||
const playlistId =
|
||||
|
||||
Reference in new issue
Block a user