feat(sources): add the shared source row and credential-free formatting

One app-source-row (regular for the Sources page, compact for the
playlist switcher) with an app-source-tile, built on pure helpers:

- resolvePlaylistSourceKind: one provider rule for icons, filters and
  counts (the list filter and the panel counts used to disagree).
- sourceDisplayName / sourceDisplayUrl: a name or URL never shows a
  password, token or MAC. URL imports that stored
  "get.php?username=…&password=…" as the title now show the host.
- stripUrlCredentials (shared/logging): drops what the log redactor
  would mask, plus short user/pass keys.
- formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line
  order and Intl dates and durations in the UI language.
- SourceStatusService + SourceHealthHistoryService: one status per
  source (desktop health check, browser Xtream portal status) and the
  last conclusive result kept across restarts, so "not responding for
  3 days" survives a relaunch. No URL or credential is stored.

Also fixes playlistNameHasCredentials answering differently on repeated
calls (global regex lastIndex), and adds the UI-kit button, menu and
segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt
in with class="app-menu", styled by the components that use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 22:34:22 +02:00
1 parent 6fe684af51
commit 028a3db880
51 files changed
+3455 -25

No files matched your search

+1
View File
@@ -112,4 +112,5 @@ export * from './lib/xtream-connection-test';
export * from './lib/portal-request-failure.util';
export * from './lib/source-health';
export * from './lib/source-reachability';
export * from './lib/server-storage.contract';
@@ -16,7 +16,9 @@ describe('playlistDisplayLabel', () => {
// The default :80 is dropped by URL normalization, which reads better
// anyway; a non-default port is kept (see the case above).
expect(
playlistDisplayLabel('http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2')
playlistDisplayLabel(
'http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2'
)
).toBe('marveltv.info');
});
@@ -67,7 +69,9 @@ describe('playlistDisplayLabel', () => {
describe('playlistNameHasCredentials', () => {
it('detects query-string credentials', () => {
expect(
playlistNameHasCredentials('http://x.tv/get.php?username=a&password=b')
playlistNameHasCredentials(
'http://x.tv/get.php?username=a&password=b'
)
).toBe(true);
});
@@ -81,4 +85,13 @@ describe('playlistNameHasCredentials', () => {
expect(playlistNameHasCredentials('cord-cutter')).toBe(false);
expect(playlistNameHasCredentials('http://plain.tv:8080')).toBe(false);
});
it('answers the same for the same name on every call', () => {
const name = 'Portal usr== abc pas== def';
expect([1, 2, 3].map(() => playlistNameHasCredentials(name))).toEqual([
true,
true,
true,
]);
});
});
@@ -12,7 +12,8 @@
*/
/** Query keys whose presence means the string is a credential-bearing URL. */
const CREDENTIAL_KEYS = /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
const CREDENTIAL_KEYS =
/(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
/** Hand-typed credential fragments, e.g. "usr== Tl24uy0xGi pas== abc". */
const INLINE_CREDENTIALS =
@@ -56,6 +57,9 @@ export function playlistNameHasCredentials(
rawName: string | null | undefined
): boolean {
const name = rawName ?? '';
// INLINE_CREDENTIALS is global for `replace`; `test` on a global pattern
// resumes at its lastIndex, so a second call would skip the match.
INLINE_CREDENTIALS.lastIndex = 0;
return CREDENTIAL_KEYS.test(name) || INLINE_CREDENTIALS.test(name);
}
@@ -0,0 +1,101 @@
import {
nextSourceHealthHistory,
sourceReachabilityFromHealth,
sourceReachabilityFromPortalStatus,
sourceStatusBucket,
} from './source-reachability';
describe('source reachability', () => {
it.each([
[{ state: 'active', reason: 'available' }, 'ok'],
[{ state: 'expired', reason: 'expired' }, 'expired'],
[{ state: 'inactive', reason: 'disabled' }, 'bad'],
[{ state: 'inactive', reason: 'auth' }, 'bad'],
[{ state: 'checking', reason: 'unknown' }, 'checking'],
[{ state: 'unavailable', reason: 'network' }, 'bad'],
[{ state: 'unavailable', reason: 'timeout' }, 'bad'],
[{ state: 'unavailable', reason: 'http' }, 'bad'],
[{ state: 'unavailable', reason: 'invalid' }, 'bad'],
// Checks that never reached the source say nothing about it.
[{ state: 'unavailable', reason: 'paused' }, 'unknown'],
[{ state: 'unavailable', reason: 'cancelled' }, 'unknown'],
[{ state: 'unavailable', reason: 'unknown' }, 'unknown'],
[{ state: 'unknown', reason: 'unknown' }, 'unknown'],
] as const)('maps %o to %s', (snapshot, expected) => {
expect(sourceReachabilityFromHealth(snapshot)).toBe(expected);
});
it('treats a source without a snapshot as never checked', () => {
expect(sourceReachabilityFromHealth(undefined)).toBe('unknown');
});
it.each([
['active', 'ok'],
['expired', 'expired'],
['inactive', 'bad'],
['unavailable', 'bad'],
['checking', 'checking'],
] as const)('maps the portal status %s to %s', (status, expected) => {
expect(sourceReachabilityFromPortalStatus(status)).toBe(expected);
});
it('files expired accounts with the sources that do not respond', () => {
expect(sourceStatusBucket('ok')).toBe('ok');
expect(sourceStatusBucket('bad')).toBe('bad');
expect(sourceStatusBucket('expired')).toBe('bad');
expect(sourceStatusBucket('checking')).toBeNull();
expect(sourceStatusBucket('unknown')).toBeNull();
});
});
describe('nextSourceHealthHistory', () => {
it('starts a failing run at the first failed check', () => {
expect(nextSourceHealthHistory(undefined, 'bad', 100)).toEqual({
state: 'bad',
checkedAt: 100,
failingSince: 100,
});
});
it('keeps the start of the run across later failures', () => {
const first = nextSourceHealthHistory(
{ state: 'ok', checkedAt: 50, lastSuccessAt: 50 },
'bad',
100
);
const second = nextSourceHealthHistory(first, 'expired', 400);
expect(second).toEqual({
state: 'expired',
checkedAt: 400,
failingSince: 100,
lastSuccessAt: 50,
});
});
it('ends the run when the source answers again', () => {
expect(
nextSourceHealthHistory(
{ state: 'bad', checkedAt: 100, failingSince: 10 },
'ok',
500
)
).toEqual({ state: 'ok', checkedAt: 500, lastSuccessAt: 500 });
});
it('keeps the previous entry for an inconclusive check', () => {
const previous = {
state: 'bad',
checkedAt: 100,
failingSince: 10,
} as const;
expect(nextSourceHealthHistory(previous, 'unknown', 900)).toBe(
previous
);
expect(nextSourceHealthHistory(previous, 'checking', 900)).toBe(
previous
);
expect(
nextSourceHealthHistory(undefined, 'unknown', 900)
).toBeUndefined();
});
});
@@ -0,0 +1,132 @@
import type { SourceHealthSnapshot } from './source-health';
/**
* What a source list shows about one network source, folded from the six
* health states: `ok` answers, `bad` does not, `expired` answers with an
* expired account, `checking` is mid-check with nothing known yet, and
* `unknown` was never checked or the check was inconclusive.
*/
export type SourceReachability =
'ok' | 'bad' | 'expired' | 'checking' | 'unknown';
/** A reachability a check actually established. */
export type ConclusiveSourceReachability = Extract<
SourceReachability,
'ok' | 'bad' | 'expired'
>;
/** The two choices of a "responds / does not respond" status filter. */
export type SourceStatusBucket = 'ok' | 'bad';
/** A network source's status as a list shows it. */
export interface SourceStatus {
reachability: SourceReachability;
/** When the current run of failed checks began. */
failingSince?: number;
checkedAt?: number;
/** A check is running; `reachability` is the last known result. */
pending?: boolean;
}
/**
* Health reasons that mean the source itself did not answer properly. A
* paused, cancelled or unknown check says nothing about the source.
*/
const UNREACHABLE_REASONS = new Set(['network', 'timeout', 'http', 'invalid']);
export function sourceReachabilityFromHealth(
snapshot: Pick<SourceHealthSnapshot, 'state' | 'reason'> | undefined
): SourceReachability {
switch (snapshot?.state) {
case 'active':
return 'ok';
case 'expired':
return 'expired';
case 'inactive':
return 'bad';
case 'checking':
return 'checking';
case 'unavailable':
return UNREACHABLE_REASONS.has(snapshot.reason) ? 'bad' : 'unknown';
default:
return 'unknown';
}
}
/** The browser build's Xtream portal status, by the same rules. */
export function sourceReachabilityFromPortalStatus(
status: 'active' | 'inactive' | 'expired' | 'unavailable' | 'checking'
): SourceReachability {
switch (status) {
case 'active':
return 'ok';
case 'expired':
return 'expired';
case 'checking':
return 'checking';
default:
return 'bad';
}
}
export function isConclusiveReachability(
reachability: SourceReachability
): reachability is ConclusiveSourceReachability {
return (
reachability === 'ok' ||
reachability === 'bad' ||
reachability === 'expired'
);
}
/**
* Expired accounts count as not responding: the source answers, but the
* account cannot play anything, which is what the filter is for.
*/
export function sourceStatusBucket(
reachability: SourceReachability
): SourceStatusBucket | null {
if (reachability === 'ok') {
return 'ok';
}
return reachability === 'bad' || reachability === 'expired' ? 'bad' : null;
}
/** The last conclusive result kept for a source across restarts. */
export interface SourceHealthHistoryEntry {
state: ConclusiveSourceReachability;
checkedAt: number;
/** When the current run of failed checks began; absent while `ok`. */
failingSince?: number;
lastSuccessAt?: number;
}
/**
* Folds one check into the kept history. An inconclusive check keeps the
* previous entry; a failure keeps the start of the current failing run, so
* a source that has not answered for three days still says three days after
* every later failed check.
*/
export function nextSourceHealthHistory(
previous: SourceHealthHistoryEntry | undefined,
reachability: SourceReachability,
checkedAt: number
): SourceHealthHistoryEntry | undefined {
if (!isConclusiveReachability(reachability)) {
return previous;
}
if (reachability === 'ok') {
return { state: 'ok', checkedAt, lastSuccessAt: checkedAt };
}
const wasFailing = previous !== undefined && previous.state !== 'ok';
return {
state: reachability,
checkedAt,
failingSince: wasFailing
? (previous.failingSince ?? previous.checkedAt)
: checkedAt,
...(previous?.lastSuccessAt !== undefined
? { lastSuccessAt: previous.lastSuccessAt }
: {}),
};
}
@@ -1,6 +1,7 @@
import {
getPlaylistSourceIcon,
resolvePlaylistSourceIconKey,
resolvePlaylistSourceKind,
SOURCE_TYPE_ICONS,
} from './source-type-icons.util';
@@ -40,4 +41,14 @@ describe('source type icons', () => {
SOURCE_TYPE_ICONS[expected]
);
});
it.each([
[{ macAddress: '00:1A:79:00:00:01', serverUrl: 'http://x' }, 'stalker'],
// An Xtream row missing a credential is still Xtream, not M3U: the
// old list filter dropped it from every type.
[{ serverUrl: 'http://xtream' }, 'xtream'],
[{}, 'm3u'],
] as const)('puts %o in the %s family', (playlist, expected) => {
expect(resolvePlaylistSourceKind(playlist)).toBe(expected);
});
});
@@ -48,3 +48,20 @@ export function getPlaylistSourceIcon(
): string {
return SOURCE_TYPE_ICONS[resolvePlaylistSourceIconKey(playlist)];
}
/** The provider family a stored playlist belongs to. */
export type PlaylistSourceKind = 'm3u' | 'xtream' | 'stalker';
/**
* The provider family by the same precedence as the icon, so every type
* filter, count and label agrees with the icon on the row: a playlist
* counted as Xtream is also listed and drawn as Xtream.
*/
export function resolvePlaylistSourceKind(
playlist: Pick<PlaylistMeta, 'macAddress' | 'serverUrl'>
): PlaylistSourceKind {
if (playlist.macAddress) {
return 'stalker';
}
return playlist.serverUrl ? 'xtream' : 'm3u';
}