mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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:
1 parent
6fe684af51
commit
028a3db880
51 files changed
+3455
-25
No files matched your search
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user