Merge origin/master into claude/server-storage-pwa

Resolves app-startup-status.component.ts: master replaced the progress
spinner with static markup (its import goes), the branch's router import
for the sign-in route stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 14:36:01 +02:00
commit 614b0d3500
239 files changed
+17820 -7244

No files matched your search

+1
View File
@@ -113,4 +113,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,51 @@
import type { SourceHealthReason } 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;
/**
* Why the check concluded what it did (`auth`, `disabled`, `timeout`…),
* when known; a list otherwise describes the reachability alone.
*/
reason?: SourceHealthReason;
}
/** 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;
/** The cause of the last failed check; absent while `ok`. */
reason?: SourceHealthReason;
/**
* Fingerprint of the connection that was checked
* (`sourceHealthFingerprint`): a result for an edited address or account
* is not this source's any more.
*/
connection?: string;
}
@@ -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';
}
@@ -64,4 +64,6 @@ export interface StalkerVodInfo {
* this to send Discover clicks to the right media type.
*/
tmdb_media_type?: TmdbMediaType;
/** See `XtreamVodInfo.tmdb_supplied_release_date` — same contract */
tmdb_supplied_release_date?: boolean;
}