Merge origin/master into claude/parental-control-feature-31dde2

Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:12:58 +02:00
commit ae5d3f49de
732 files changed
+42572 -15380

No files matched your search

@@ -139,8 +139,16 @@ describe('runMigrations error tolerance', () => {
});
});
describe('TMDB search lookup v2 cache cleanup', () => {
it('deletes legacy search rows and records the migration atomically', () => {
describe('TMDB search lookup cache cleanup', () => {
function deleteStatements(sqlite: SqliteHandle): string[] {
return (sqlite.prepare as jest.Mock).mock.calls
.map(([statement]) => compactSql(statement))
.filter((statement) =>
statement.includes('DELETE FROM tmdb_metadata')
);
}
it('deletes every retired key generation and records each migration atomically', () => {
const deleteRun = jest.fn();
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
@@ -151,22 +159,56 @@ describe('TMDB search lookup v2 cache cleanup', () => {
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledWith(
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
);
const deleteSql = compactSql(
(sqlite.prepare as jest.Mock).mock.calls.find(([statement]) =>
statement.includes('DELETE FROM tmdb_metadata')
)?.[0]
);
expect(deleteSql).toContain(
expect(transaction).toHaveBeenCalledTimes(3);
expect(deleteRun).toHaveBeenCalledTimes(3);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v2-cache-cleanup:v1',
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
const [unversionedDelete, v2Delete, v3Delete] =
deleteStatements(sqlite);
expect(unversionedDelete).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
// Each generation deletes only its own rows: the v4 rows the resolver
// writes now, and the `id:`/`person:`/`badProviderId:` rows, survive.
expect(v2Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v2'");
expect(v2Delete).not.toContain('v3');
expect(v3Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v3'");
expect(v3Delete).not.toContain('v4');
});
it('does nothing after the migration has completed', () => {
it('runs only the generations that have not completed yet', () => {
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
[
'SELECT value FROM app_state',
{
get: (key: unknown) =>
key ===
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
? { value: 'done' }
: undefined,
},
],
['INSERT INTO app_state', { run: markerRun }],
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(2);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
expect(deleteStatements(sqlite)).toEqual([
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v2'"),
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v3'"),
]);
});
it('does nothing after every migration has completed', () => {
const { sqlite, prepare, transaction } = createSqliteMock([
completedMigrationStateRule,
]);
@@ -174,7 +216,8 @@ describe('TMDB search lookup v2 cache cleanup', () => {
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).not.toHaveBeenCalled();
expect(prepare).toHaveBeenCalledTimes(1);
// One marker read per retired generation, nothing else
expect(prepare).toHaveBeenCalledTimes(3);
});
});
+84 -40
View File
@@ -43,6 +43,10 @@ const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
'migration:epg-program-source-url-backfill:v1';
const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
@@ -966,50 +970,90 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
}
/**
* Search-match cache keys gained a v2 suffix when title normalization changed.
* Remove the now-unreachable unversioned rows once rather than leaving negative
* resolutions and other legacy search matches in long-lived installations.
* Every search-match cache key generation that has been retired, oldest
* first, each with the predicate selecting exactly the rows written under
* it. A retired generation's rows are unreachable — the resolver only ever
* reads the current key — so they would otherwise sit in long-lived
* installations forever, negative resolutions included.
*
* - unversioned → v2: title normalization learned to strip appended
* language/quality tags.
* - v2 → v3: the search query stopped being the folded comparison key. Under
* v2 every title with a Cyrillic "й"/"ё" was searched folded — the fold
* spells them "и" and "е", as in the illustrative "леика" for "Лейка" —
* got no answer, and was cached as missing for 7 days.
* - v3 → v4: year evidence became tiered. Under v3 a series admitted only by
* the "premiered earlier" tolerance competed with an exact-year match on
* popularity alone, so a new series resolved to its older, better-known
* namesake — and that positive row stays fresh for 30 days.
*/
const LEGACY_TMDB_SEARCH_CACHE_CLEANUPS: ReadonlyArray<{
migrationKey: string;
rowPredicate: string;
}> = [
{
migrationKey: TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%'
AND lookup_key NOT LIKE 'title:%|year:%|v%'`,
},
{
migrationKey: TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%|v2'`,
},
{
migrationKey: TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%|v3'`,
},
];
/**
* Remove search-match rows written under a retired key generation, once per
* generation, recorded in `app_state`. Each generation is its own marker so
* an installation that skipped a release still runs every cleanup it missed,
* in order.
*/
function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
{ value?: unknown } | undefined;
for (const cleanup of LEGACY_TMDB_SEARCH_CACHE_CLEANUPS) {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(cleanup.migrationKey) as { value?: unknown } | undefined;
if (migrationState?.value === 'done') {
return;
if (migrationState?.value === 'done') {
continue;
}
const executeCleanup = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`DELETE FROM tmdb_metadata
WHERE ${cleanup.rowPredicate}`
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(cleanup.migrationKey);
});
executeCleanup();
} catch (error) {
const message =
typeof error === 'object' &&
error !== null &&
'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
);
}
const executeCleanup = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`DELETE FROM tmdb_metadata
WHERE lookup_key LIKE 'title:%|year:%'
AND lookup_key NOT LIKE 'title:%|year:%|v%'`
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY);
});
executeCleanup();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
);
}
}
@@ -0,0 +1,174 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
/**
* Real-SQLite coverage for the retired search-key cleanups, run inside
* Electron so better-sqlite3 links against the ABI the app ships with. The
* mock-based spec proves the SQL shape; this one proves what a persisted
* database looks like after an upgrade — from a release that skipped every
* cleanup, from the previous release, on a fresh database, and on the next
* startup after that.
*/
it('drops only retired search rows across skipped, previous, pre-person, fresh and repeated startups', () => {
const electron = createRequire(__filename)('electron') as string;
const connectionUrl = pathToFileURL(
resolve(__dirname, 'connection.ts')
).href;
const result = execFileSync(
electron,
[
'--import',
'tsx',
'--eval',
`
const { default: Database } = await import('better-sqlite3');
const { __databaseConnectionTestHooks: hooks } = await import(${JSON.stringify(connectionUrl)});
console.log = () => undefined;
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const ROWS = [
['tv', 'title:леика|year:2026', 'ru-RU', null],
['tv', 'title:леика|year:2026|v2', 'ru-RU', null],
['tv', 'title:the boys|year:2019|v2', 'en-US', 76479],
['tv', 'title:лейка|year:2026|v3', 'ru-RU', 101101],
['tv', 'title:nightfall|year:2026|v4', 'ru-RU', 424242],
['tv', 'id:101101|v2', 'ru-RU', 101101],
['tv', 'id:101101|season:1', 'ru-RU', 101101],
['person', 'person:287', 'en-US', 287],
['movie', 'badProviderId:999', 'any', null],
['movie', 'trending:week', 'en-US', null],
];
function openDb(markers) {
const db = new Database(':memory:');
hooks.createTables(db);
const insert = db.prepare('INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id, payload) VALUES (?, ?, ?, ?, ?)');
for (const [type, key, lang, id] of ROWS) insert.run(type, key, lang, id, id === null ? null : '{"id":' + id + '}');
const mark = db.prepare("INSERT INTO app_state (key, value, updated_at) VALUES (?, 'done', datetime('now'))");
for (const marker of markers) mark.run(marker);
return db;
}
function snapshot(db) {
return {
keys: db.prepare('SELECT lookup_key FROM tmdb_metadata ORDER BY lookup_key').all().map((r) => r.lookup_key),
payloads: db.prepare("SELECT payload FROM tmdb_metadata WHERE lookup_key LIKE 'id:%' ORDER BY lookup_key").all().map((r) => r.payload),
markers: db.prepare("SELECT key FROM app_state WHERE key LIKE 'migration:tmdb-search-lookup-%' AND value = 'done' ORDER BY key").all().map((r) => r.key),
};
}
// Skipped every release since the unversioned keys: both cleanups run
// through the real initialization path
const skipped = openDb([]);
hooks.runMigrations(skipped);
const skippedAfter = snapshot(skipped);
// Next startup: a row written meanwhile under the current key survives
skipped.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:сосенка|year:2026|v4', 'ru-RU', 101103)").run();
hooks.runMigrations(skipped);
const repeated = snapshot(skipped);
// Previous release: the earlier cleanups already ran; only v3 rows go
const previous = openDb([V2_MARKER, V3_MARKER]);
hooks.runMigrations(previous);
const previousAfter = snapshot(previous);
hooks.runMigrations(previous);
const previousRepeated = snapshot(previous);
// Oldest historical schema: the pre-'person' CHECK. That migration
// rebuilds the pure-cache table empty by design; the cleanups must
// still record their markers on the rebuilt table and stay idempotent.
const prePerson = new Database(':memory:');
hooks.createTables(prePerson);
prePerson.exec("DROP TABLE tmdb_metadata; CREATE TABLE tmdb_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv')), lookup_key TEXT NOT NULL, language TEXT NOT NULL, tmdb_id INTEGER, payload TEXT, fetched_at TEXT DEFAULT (datetime('now'))); CREATE UNIQUE INDEX tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)");
prePerson.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:леика|year:2026', 'ru-RU', NULL)").run();
hooks.runMigrations(prePerson);
const prePersonAfter = { ...snapshot(prePerson), check: prePerson.prepare("SELECT sql FROM sqlite_master WHERE name = 'tmdb_metadata'").get().sql.includes("'person'") };
hooks.runMigrations(prePerson);
const prePersonRepeated = snapshot(prePerson);
// Fresh database through the real initialization path
const fresh = new Database(':memory:');
hooks.createTables(fresh);
hooks.runMigrations(fresh);
const freshAfter = snapshot(fresh);
hooks.runMigrations(fresh);
const freshRepeated = snapshot(fresh);
process.stdout.write(JSON.stringify({ skippedAfter, repeated, previousAfter, previousRepeated, prePersonAfter, prePersonRepeated, freshAfter, freshRepeated }));
`,
],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'),
},
}
);
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const survivors = [
'badProviderId:999',
'id:101101|season:1',
'id:101101|v2',
'person:287',
'title:nightfall|year:2026|v4',
'trending:week',
];
const detailsPayloads = ['{"id":101101}', '{"id":101101}'];
expect(JSON.parse(result)).toEqual({
skippedAfter: {
keys: survivors,
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
repeated: {
keys: [...survivors, 'title:сосенка|year:2026|v4'].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
previousAfter: {
// The older generations' rows are their business, already done
keys: [
...survivors,
'title:леика|year:2026',
'title:леика|year:2026|v2',
'title:the boys|year:2019|v2',
].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
previousRepeated: {
keys: [
...survivors,
'title:леика|year:2026',
'title:леика|year:2026|v2',
'title:the boys|year:2019|v2',
].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
prePersonAfter: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
check: true,
},
prePersonRepeated: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
freshAfter: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
freshRepeated: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
});
});
+2 -1
View File
@@ -9,7 +9,8 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"esModuleInterop": true
"esModuleInterop": true,
"ignoreDeprecations": "6.0"
},
"files": [],
"include": [],
+2
View File
@@ -1 +1,3 @@
export * from './lib/host-connectivity-guard';
export * from './lib/network-family-autoselection';
export * from './lib/socket-connect-observer';
@@ -37,6 +37,36 @@ describe('classifyHostRequestFailure', () => {
}
});
it('reads a timeout after an accepted connection as inconclusive, never host-level', () => {
// axios raises the same code whether the SYN went unanswered or the
// panel accepted the connection and then thought for longer than
// the budget. Only the former is a dead host. The accepted
// connection was reported when it happened; by the time the
// timeout settles it is stale evidence and must not clear failures
// recorded since.
for (const code of ['ECONNABORTED', 'ETIMEDOUT']) {
expect(
classifyHostRequestFailure(timeoutError(code), {
connected: true,
})
).toBe('inconclusive');
expect(
classifyHostRequestFailure(timeoutError(code), {
connected: false,
})
).toBe('host-level');
}
// An HTTP response outranks the flag either way.
expect(
classifyHostRequestFailure(
Object.assign(timeoutError('ECONNABORTED'), {
response: { status: 504 },
}),
{ connected: true }
)
).toBe('responded');
});
it('treats an error carrying an HTTP response as proof the host answered', () => {
// 5xx reaches the handlers as a rejection: validateStatus only
// tolerates < 500. The host still answered.
@@ -208,6 +238,28 @@ describe('HostConnectivityGuard', () => {
});
});
it('lets an accepted connection clear the streak without closing an open breaker', () => {
// One refusal, then a request whose connection is accepted: the
// streak restarts from zero, so the next refusal is not the second.
failRequest();
guard.reportConnected(expectAllowed());
failRequest();
expectAllowed();
expect(opened).toEqual([]);
// A second refusal in a row opens it. The half-open trial connects
// but stays silent: the breaker keeps waiting for the trial to
// settle instead of closing on the handshake alone.
failRequest();
expect(opened).toEqual([HOST]);
advance(OPEN_DURATION_MS + 1);
const trial = expectAllowed();
guard.reportConnected(trial);
expectBlocked();
guard.reportInconclusive(trial);
expectAllowed();
});
it('allows requests to a host it has never seen', () => {
expect(guard.check(HOST).allowed).toBe(true);
expect(opened).toEqual([]);
@@ -115,6 +115,13 @@ export type HostConnectivityCheck =
| { readonly allowed: true; readonly token: HostRequestToken }
| { readonly allowed: false; readonly retryAfterMs: number };
/**
* `responded`: the endpoint answered with an HTTP response of any status.
* `host-level`: the host never answered. `inconclusive`: the failure says
* nothing about reachability — including a timeout after an accepted
* connection, whose evidence the owner already reported at connect time (see
* {@link classifyHostRequestFailure}).
*/
export type HostRequestOutcome = 'responded' | 'host-level' | 'inconclusive';
interface HostState {
@@ -154,7 +161,40 @@ export function isHostConnectivityGuardDisabled(): boolean {
* that says nothing about reachability: a cancelled request, a URL rejected by
* the SSRF policy, a bug in our own code.
*/
export function classifyHostRequestFailure(error: unknown): HostRequestOutcome {
export interface HostRequestFailureContext {
/**
* Whether the transport saw the TCP connection for this request get
* established (or handed the request a socket that already was).
*/
readonly connected?: boolean;
}
/**
* Reads what a failed request proves about its endpoint.
*
* `context.connected` matters for the timeout codes: axios raises the same
* `ECONNABORTED` whether the SYN went unanswered or the panel accepted the
* connection and then thought for longer than the request budget. Only the
* former is a host that stopped answering. A panel that is merely slow (a
* heavy `get_vod_info` on a busy home server) accepts every connection, and
* tripping the breaker on it turns "slow" into thirty seconds of "not
* responding" for every request — the reported symptom. So a host-level
* code observed after the handshake is `inconclusive`: it must not count.
*
* It does not clear the streak here either, and that is deliberate. The
* accepted connection IS reachability evidence and does clear the streak —
* but the owner reports it the moment the socket connects
* (`reportConnected` from the transport's connect hook), not when the
* timeout settles up to 30 s later. Ordering matters: while A sits connected and
* silent, B and C can fail to connect and open the breaker for a host that
* has just died; clearing at A's settle time would reopen it on evidence
* older than B's and C's failures. The remaining codes cannot occur once a
* connection exists, so the rule costs nothing for them.
*/
export function classifyHostRequestFailure(
error: unknown,
context: HostRequestFailureContext = {}
): HostRequestOutcome {
if (!error || typeof error !== 'object') {
return 'inconclusive';
}
@@ -165,7 +205,7 @@ export function classifyHostRequestFailure(error: unknown): HostRequestOutcome {
const code = (error as { code?: unknown }).code;
if (typeof code === 'string' && HOST_LEVEL_FAILURE_CODES.has(code)) {
return 'host-level';
return context.connected ? 'inconclusive' : 'host-level';
}
return 'inconclusive';
@@ -279,6 +319,28 @@ export class HostConnectivityGuard {
state.lastTouchedAt = this.now();
}
/**
* The host accepted this request's TCP connection. Reported the moment it
* happens, so its place in the order of evidence is exact: it clears the
* failure streak recorded up to now, and later failures start a new one.
*
* It deliberately does NOT close an open or half-open breaker. Whether a
* host that has just accepted a connection also answers is exactly what
* the half-open trial exists to find out, so the trial keeps its slot
* until it settles, and a request admitted before the breaker opened does
* not reopen the host on the strength of a handshake alone.
*/
reportConnected(token: HostRequestToken): void {
const state = this.states.get(token.endpoint);
if (!state || isHostConnectivityGuardDisabled()) {
return;
}
state.consecutiveFailures = 0;
state.lastFailureAt = 0;
state.lastTouchedAt = this.now();
}
/** The host did not answer at all. */
reportFailure(token: HostRequestToken): void {
const state = this.states.get(token.endpoint);
@@ -0,0 +1,158 @@
import net from 'node:net';
import {
applyDefaultAutoSelectFamilyAttemptTimeout,
AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG,
DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS,
hasExplicitAttemptTimeoutFlag,
} from './network-family-autoselection';
describe('network family autoselection tuning', () => {
it('raises the attempt timeout when the operator set nothing', () => {
const setAttemptTimeout = jest.fn();
const applied = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: [],
nodeOptions: '',
setAttemptTimeout,
});
expect(applied).toBe(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS);
expect(setAttemptTimeout).toHaveBeenCalledWith(
DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS
);
});
it('keeps an explicit --network-family-autoselection-attempt-timeout=<ms> flag', () => {
const setAttemptTimeout = jest.fn();
const applied = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: [`${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=1000`],
nodeOptions: '',
setAttemptTimeout,
});
expect(applied).toBeNull();
expect(setAttemptTimeout).not.toHaveBeenCalled();
});
it('keeps the flag when it is passed as a separate argument', () => {
const setAttemptTimeout = jest.fn();
const applied = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: [AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG, '1000'],
nodeOptions: '',
setAttemptTimeout,
});
expect(applied).toBeNull();
expect(setAttemptTimeout).not.toHaveBeenCalled();
});
it('keeps the flag when it arrives through NODE_OPTIONS', () => {
const setAttemptTimeout = jest.fn();
const applied = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: [],
nodeOptions: `--dns-result-order=ipv4first ${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=500`,
setAttemptTimeout,
});
expect(applied).toBeNull();
expect(setAttemptTimeout).not.toHaveBeenCalled();
});
it('keeps the underscore spelling Node also accepts', () => {
const setAttemptTimeout = jest.fn();
const viaArgv = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: ['--network_family_autoselection_attempt_timeout=500'],
nodeOptions: '',
setAttemptTimeout,
});
const viaNodeOptions = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: [],
nodeOptions: '--network_family_autoselection_attempt_timeout=700',
setAttemptTimeout,
});
expect(viaArgv).toBeNull();
expect(viaNodeOptions).toBeNull();
expect(setAttemptTimeout).not.toHaveBeenCalled();
});
it('detects the flag in both argv shapes and NODE_OPTIONS', () => {
expect(
hasExplicitAttemptTimeoutFlag(
[`${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=250`],
''
)
).toBe(true);
expect(
hasExplicitAttemptTimeoutFlag(
[AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG],
''
)
).toBe(true);
expect(
hasExplicitAttemptTimeoutFlag(
['--network_family-autoselection_attempt-timeout=250'],
''
)
).toBe(true);
expect(
hasExplicitAttemptTimeoutFlag(
[],
`${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG} 250`
)
).toBe(true);
expect(
hasExplicitAttemptTimeoutFlag(
['--no-network-family-autoselection'],
'--dns-result-order=ipv4first'
)
).toBe(false);
});
it('ignores the flag text embedded in another option value or name', () => {
const setAttemptTimeout = jest.fn();
// The flag string inside another option's VALUE is not the option.
const applied = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: [],
nodeOptions:
'--title=--network-family-autoselection-attempt-timeout-worker',
setAttemptTimeout,
});
expect(applied).toBe(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS);
expect(setAttemptTimeout).toHaveBeenCalledWith(
DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS
);
// A longer option NAME that merely starts with the flag text is a
// different option.
expect(
hasExplicitAttemptTimeoutFlag(
['--network-family-autoselection-attempt-timeout-worker=5'],
''
)
).toBe(false);
});
it('applies the timeout to the real net default when nothing is injected', () => {
const original = net.getDefaultAutoSelectFamilyAttemptTimeout();
try {
const applied = applyDefaultAutoSelectFamilyAttemptTimeout({
execArgv: [],
nodeOptions: '',
});
expect(applied).toBe(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS);
expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(
DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS
);
} finally {
net.setDefaultAutoSelectFamilyAttemptTimeout(original);
}
});
});
@@ -0,0 +1,88 @@
import net from 'node:net';
/**
* Node enables "Happy Eyeballs" connection racing (`autoSelectFamily`) by
* default and gives every address attempt only 250 ms before moving on to
* the next one. Behind VPN or Docker networks a perfectly working IPv4
* handshake routinely needs more than that, so a dual-stack provider
* hostname with an unreachable IPv6 route exhausts all attempts
* (`ENETUNREACH` on IPv6, `ETIMEDOUT` on IPv4) and surfaces as a bare
* 502 (#1400). Raising the per-attempt budget keeps the IPv6→IPv4 fallback
* intact — unlike `--no-network-family-autoselection`, which would break
* IPv6-only deployments — while tolerating slow links.
*/
export const DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 2500;
export const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG =
'--network-family-autoselection-attempt-timeout';
export interface ApplyAutoSelectFamilyAttemptTimeoutOptions {
readonly execArgv?: readonly string[];
readonly nodeOptions?: string;
readonly setAttemptTimeout?: (timeoutMs: number) => void;
}
/**
* Node accepts underscores interchangeably with dashes in flag names
* (`process.allowedNodeEnvironmentFlags` semantics), so
* `--network_family_autoselection_attempt_timeout=500` is just as explicit
* as the canonical spelling and must be honored too.
*/
function normalizeFlagSpelling(value: string): string {
return value.replace(/_/g, '-');
}
/**
* The flag must match as a complete option token — a raw substring search
* would also fire on the flag text embedded in another option's value
* (e.g. `--title=--network-family-autoselection-attempt-timeout-worker`)
* and silently skip the 2500 ms default.
*/
function matchesAttemptTimeoutFlag(token: string): boolean {
const normalized = normalizeFlagSpelling(token);
return (
normalized === AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG ||
normalized.startsWith(`${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=`)
);
}
/**
* An operator who tunes the timeout through Node's own CLI flag (directly or
* via `NODE_OPTIONS`) must keep the final word: the flag is applied before
* user code runs, so overwriting it here would silently undo their setting.
*/
export function hasExplicitAttemptTimeoutFlag(
execArgv: readonly string[],
nodeOptions: string
): boolean {
return (
execArgv.some(matchesAttemptTimeoutFlag) ||
nodeOptions.split(/\s+/).some(matchesAttemptTimeoutFlag)
);
}
/**
* Raises the process-wide happy-eyeballs attempt timeout unless the operator
* already set one explicitly. Returns the applied value, or `null` when the
* explicit flag won (or the running Node build lacks the setter).
*/
export function applyDefaultAutoSelectFamilyAttemptTimeout(
options: ApplyAutoSelectFamilyAttemptTimeoutOptions = {}
): number | null {
const execArgv = options.execArgv ?? process.execArgv;
const nodeOptions =
options.nodeOptions ?? process.env['NODE_OPTIONS'] ?? '';
if (hasExplicitAttemptTimeoutFlag(execArgv, nodeOptions)) {
return null;
}
const setAttemptTimeout =
options.setAttemptTimeout ??
net.setDefaultAutoSelectFamilyAttemptTimeout;
if (typeof setAttemptTimeout !== 'function') {
return null;
}
setAttemptTimeout(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS);
return DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS;
}
@@ -0,0 +1,100 @@
import { createServer, Server, Agent, request as httpRequest } from 'node:http';
import { AddressInfo } from 'node:net';
import { observeAgentSocketConnections } from './socket-connect-observer';
async function withSilentServer<T>(
run: (port: number, server: Server) => Promise<T>
): Promise<T> {
// Accepts every connection and never writes a byte: a live but silent host.
const server = createServer(() => undefined);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
return await run((server.address() as AddressInfo).port, server);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
function requestOnce(
agent: Agent,
port: number,
timeoutMs: number
): Promise<{ error?: NodeJS.ErrnoException }> {
return new Promise((resolve) => {
const request = httpRequest(
{ host: '127.0.0.1', port, path: '/', agent, timeout: timeoutMs },
() => resolve({})
);
request.on('timeout', () =>
request.destroy(
Object.assign(new Error('timeout'), { code: 'ECONNABORTED' })
)
);
request.on('error', (error: NodeJS.ErrnoException) =>
resolve({ error })
);
request.end();
});
}
describe('observeAgentSocketConnections', () => {
it('reports the connect of a fresh connection even when the host then never answers', async () => {
await withSilentServer(async (port) => {
const onConnect = jest.fn();
const agent = observeAgentSocketConnections(new Agent(), onConnect);
const { error } = await requestOnce(agent, port, 100);
expect(error?.code).toBe('ECONNABORTED');
expect(onConnect).toHaveBeenCalledTimes(1);
});
});
it('stays silent when the host refuses the connection', async () => {
// A listener that has just closed: nothing is accepting on that port.
const port = await withSilentServer(async (port) => port);
const onConnect = jest.fn();
const agent = observeAgentSocketConnections(new Agent(), onConnect);
const { error } = await requestOnce(agent, port, 1_000);
expect(error?.code).toBe('ECONNREFUSED');
expect(onConnect).not.toHaveBeenCalled();
});
it('reports a pooled keep-alive socket as connected right away', async () => {
const server = createServer((_request, response) => response.end('ok'));
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const onConnect = jest.fn();
const agent = observeAgentSocketConnections(
new Agent({ keepAlive: true }),
onConnect
);
const fetchOk = (port: number) =>
new Promise<void>((resolve) => {
httpRequest({ host: '127.0.0.1', port, agent }, (response) =>
response.resume().on('end', resolve)
).end();
});
try {
const port = (server.address() as AddressInfo).port;
await fetchOk(port);
expect(onConnect).toHaveBeenCalledTimes(1);
// Same agent, same idle socket: no `connect` event will ever fire
// again for it, yet the host demonstrably accepted it.
expect(
Object.values(agent.freeSockets).flat()
).toHaveLength(1);
await fetchOk(port);
expect(onConnect).toHaveBeenCalledTimes(2);
} finally {
agent.destroy();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
@@ -0,0 +1,59 @@
import type { ClientRequest } from 'node:http';
import type { Socket } from 'node:net';
/**
* The one method every Node HTTP/HTTPS agent funnels a request through, pooled
* socket or fresh connection alike. `@types/node` leaves it undeclared, so the
* shape is spelled out here rather than reached through `any`.
*/
interface RequestAcceptingAgent {
addRequest(request: ClientRequest, ...rest: unknown[]): void;
}
/**
* Reports the moment the TCP connection carrying `request` is known to be
* established. A keep-alive agent can hand the request a socket whose
* `connect` event fired long ago, so a socket that is no longer connecting
* counts right away: the host DID accept a connection, which is the fact
* wanted. A socket that never connects (refused, unresolvable, unanswered
* SYN) never reports.
*/
export function observeRequestSocketConnect(
request: ClientRequest,
onConnect: () => void
): void {
request.once('socket', (socket: Socket) => {
if (socket.connecting) {
socket.once('connect', onConnect);
} else {
onConnect();
}
});
}
/**
* {@link observeRequestSocketConnect} for a transport that never hands its
* `ClientRequest` back (axios' default adapter): hooking the agent's
* `addRequest` sees every request the agent takes, pooled or fresh.
*
* Mutates and returns the same agent so it can be passed straight into an
* axios config. Never install this on a shared agent such as
* `http.globalAgent`: the callback belongs to one request, and a second
* install on the same agent stacks on the first rather than replacing it.
*/
export function observeAgentSocketConnections<TAgent extends object>(
agent: TAgent,
onConnect: () => void
): TAgent {
const target = agent as unknown as RequestAcceptingAgent;
const original = target.addRequest;
target.addRequest = function observedAddRequest(
this: RequestAcceptingAgent,
request: ClientRequest,
...rest: unknown[]
): void {
observeRequestSocketConnect(request, onConnect);
return original.call(this, request, ...rest);
};
return agent;
}
+2 -1
View File
@@ -8,7 +8,8 @@
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
"noPropertyAccessFromIndexSignature": true,
"ignoreDeprecations": "6.0"
},
"files": [],
"include": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"ngx-indexed-db": "^22.0.0",
"ngx-indexed-db": "22.0.0",
"tslib": "^2.3.0"
}
}
+5
View File
@@ -1,3 +1,4 @@
export * from './lib/app-update-channel.util';
export * from './lib/channel-drm.interface';
export * from './lib/channel.interface';
export * from './lib/channel.model';
@@ -6,6 +7,7 @@ export * from './lib/dev-logger.util';
export * from './lib/download-metadata.interface';
export * from './lib/embedded-mpv-session.interface';
export * from './lib/subtitle-style.util';
export * from './lib/zoom-level.util';
export * from './lib/electron-api.interface';
export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
@@ -14,6 +16,7 @@ export * from './lib/epg-display-offset.util';
export * from './lib/epg-item.interface';
export * from './lib/epg-mapping-key.util';
export * from './lib/epg-program.model';
export * from './lib/epg-source-reference.util';
export * from './lib/external-player-arguments.utils';
export * from './lib/embedded-mpv-extra-options.util';
export * from './lib/external-player-session.interface';
@@ -47,6 +50,7 @@ export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/recording-metadata.interface';
export * from './lib/recording-program-overlap.util';
export * from './lib/renderer-reload-route.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-auth-failure.util';
@@ -58,6 +62,7 @@ export * from './lib/stream-format.enum';
export * from './lib/catalog-title-match.interface';
export * from './lib/theme.enum';
export * from './lib/season-marker.util';
export * from './lib/search-text-fold.util';
export * from './lib/stalker-account-info-dialog-data.interface';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
@@ -0,0 +1,126 @@
import {
appUpdateReleasesApiUrl,
appUpdateReleasesListUrl,
appUpdateReleasesPageUrl,
buildAppUpdateReleaseNotesNotFoundMessage,
isAppUpdateReleaseNotesNotFoundMessage,
appUpdateRepository,
appVersionChannel,
compareAppVersions,
normalizeAppUpdateChannel,
parseAppVersion,
} from './app-update-channel.util';
describe('app update channel util', () => {
it('collapses unknown channels to stable', () => {
expect(normalizeAppUpdateChannel('nightly')).toBe('nightly');
expect(normalizeAppUpdateChannel('stable')).toBe('stable');
expect(normalizeAppUpdateChannel('canary')).toBe('stable');
expect(normalizeAppUpdateChannel(undefined)).toBe('stable');
expect(normalizeAppUpdateChannel(null)).toBe('stable');
});
it('maps each channel to its repository and pages', () => {
expect(appUpdateRepository('stable')).toEqual({
owner: '4gray',
repo: 'iptvnator',
});
expect(appUpdateRepository('nightly')).toEqual({
owner: '4gray',
repo: 'iptvnator-nightly',
});
// /releases/latest never resolves to a prerelease.
expect(appUpdateReleasesPageUrl('stable')).toBe(
'https://github.com/4gray/iptvnator/releases/latest'
);
expect(appUpdateReleasesPageUrl('nightly')).toBe(
'https://github.com/4gray/iptvnator-nightly/releases'
);
expect(appUpdateReleasesApiUrl('nightly')).toBe(
'https://api.github.com/repos/4gray/iptvnator-nightly/releases'
);
expect(appUpdateReleasesListUrl('stable')).toBe(
'https://github.com/4gray/iptvnator/releases'
);
expect(appUpdateReleasesListUrl('nightly')).toBe(
'https://github.com/4gray/iptvnator-nightly/releases'
);
});
it('recognises the not-found rejection through the IPC wrapper', () => {
const message = buildAppUpdateReleaseNotesNotFoundMessage('0.24.0');
expect(message).toBe('Release notes were not found for 0.24.0');
expect(buildAppUpdateReleaseNotesNotFoundMessage(undefined)).toBe(
'Release notes were not found for latest release'
);
expect(
isAppUpdateReleaseNotesNotFoundMessage(
`Error invoking remote method 'APP_UPDATE:GET_RELEASE_NOTES': Error: ${message}`
)
).toBe(true);
expect(
isAppUpdateReleaseNotesNotFoundMessage(
'GitHub releases request failed: 403 Forbidden'
)
).toBe(false);
expect(isAppUpdateReleaseNotesNotFoundMessage(null)).toBe(false);
});
it('reads the channel a build belongs to off its version', () => {
expect(appVersionChannel('0.23.0')).toBe('stable');
expect(appVersionChannel('v0.23.0')).toBe('stable');
expect(appVersionChannel('0.23.1-nightly.20260915.1234')).toBe(
'nightly'
);
expect(appVersionChannel('v0.23.1-nightly.20260915.1234')).toBe(
'nightly'
);
expect(appVersionChannel('0.23.1-beta.1')).toBe('stable');
expect(appVersionChannel('junk')).toBe('stable');
expect(appVersionChannel(undefined)).toBe('stable');
});
it('parses core and prerelease identifiers', () => {
expect(parseAppVersion('v0.23.1-nightly.20260915.7+abc')).toEqual({
core: [0, 23, 1],
prerelease: ['nightly', '20260915', '7'],
});
expect(parseAppVersion('0.23')).toBeNull();
});
it('orders versions by semver precedence', () => {
expect(compareAppVersions('0.24.0', '0.23.0')).toBe(1);
expect(compareAppVersions('0.23.0', '0.23.0')).toBe(0);
// A nightly sits between the released version and the next patch.
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.0')).toBe(
1
);
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.1')).toBe(
-1
);
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.24.0')).toBe(
-1
);
// Same day: the run number decides numerically, not lexically.
expect(
compareAppVersions(
'0.23.1-nightly.20260915.1000',
'0.23.1-nightly.20260915.999'
)
).toBe(1);
expect(
compareAppVersions(
'0.23.1-nightly.20260916.1',
'0.23.1-nightly.20260915.999'
)
).toBe(1);
// Fewer identifiers rank lower; numeric identifiers rank below words.
expect(compareAppVersions('0.23.1-nightly', '0.23.1-nightly.1')).toBe(
-1
);
expect(compareAppVersions('0.23.1-1', '0.23.1-alpha')).toBe(-1);
// Junk never looks like an update.
expect(compareAppVersions('junk', '0.23.0')).toBe(0);
});
});
@@ -0,0 +1,208 @@
/**
* Desktop update channel. Electron only.
*
* `stable` follows the tagged releases of `4gray/iptvnator`; `nightly`
* follows the prereleases that every master merge publishes to
* `4gray/iptvnator-nightly`. The value is persisted with the other settings
* in the renderer and mirrored into the main-process config by the
* `SETTINGS_UPDATE` handler, because the startup update check runs before
* any renderer exists to ask.
*
* Channel switches are forward-only on purpose: a nightly build stays
* installed until the next stable version is newer than it. A downgrade
* could land on a release that does not understand the database schema a
* nightly migration already applied.
*/
export type AppUpdateChannel = 'stable' | 'nightly';
export const APP_UPDATE_CHANNELS: readonly AppUpdateChannel[] = [
'stable',
'nightly',
];
export const DEFAULT_APP_UPDATE_CHANNEL: AppUpdateChannel = 'stable';
/**
* Prerelease identifier every nightly version carries
* (`0.23.1-nightly.20260915.1234`). The nightly build sets the same name as
* electron-builder's publish channel, which names the updater metadata
* (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`), and
* electron-updater matches release tags on the same identifier.
*/
export const NIGHTLY_PRERELEASE_TAG = 'nightly';
/** Collapses anything that is not a known channel to `stable`. */
export function normalizeAppUpdateChannel(value: unknown): AppUpdateChannel {
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
? (value as AppUpdateChannel)
: DEFAULT_APP_UPDATE_CHANNEL;
}
interface AppUpdateRepository {
owner: string;
repo: string;
}
const APP_UPDATE_REPOSITORIES: Record<AppUpdateChannel, AppUpdateRepository> = {
stable: { owner: '4gray', repo: 'iptvnator' },
nightly: { owner: '4gray', repo: 'iptvnator-nightly' },
};
export function appUpdateRepository(
channel: AppUpdateChannel
): AppUpdateRepository {
return { ...APP_UPDATE_REPOSITORIES[channel] };
}
/**
* Page a user can open to install by hand. `/releases/latest` never resolves
* to a prerelease, and the nightly repository holds nothing else, so that
* channel links to the release list instead.
*/
export function appUpdateReleasesPageUrl(channel: AppUpdateChannel): string {
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
const base = `https://github.com/${owner}/${repo}/releases`;
return channel === 'stable' ? `${base}/latest` : base;
}
/** The channel's release list; the page to browse when one version is missing. */
export function appUpdateReleasesListUrl(channel: AppUpdateChannel): string {
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
return `https://github.com/${owner}/${repo}/releases`;
}
export function appUpdateReleasesApiUrl(channel: AppUpdateChannel): string {
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
return `https://api.github.com/repos/${owner}/${repo}/releases`;
}
const VERSION_PATTERN =
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
interface ParsedAppVersion {
core: [number, number, number];
prerelease: string[];
}
export function parseAppVersion(
value: string | null | undefined
): ParsedAppVersion | null {
const match = (value ?? '').trim().match(VERSION_PATTERN);
if (!match) {
return null;
}
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4] ? match[4].split('.') : [],
};
}
/** The channel a build belongs to, read from its version string. */
export function appVersionChannel(
version: string | null | undefined
): AppUpdateChannel {
return parseAppVersion(version)?.prerelease[0] === NIGHTLY_PRERELEASE_TAG
? 'nightly'
: 'stable';
}
function compareIdentifiers(left: string, right: string): number {
const leftNumeric = /^\d+$/.test(left);
const rightNumeric = /^\d+$/.test(right);
if (leftNumeric && rightNumeric) {
return Math.sign(Number(left) - Number(right));
}
// Semver: numeric identifiers always have lower precedence.
if (leftNumeric !== rightNumeric) {
return leftNumeric ? -1 : 1;
}
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* Semver precedence for the version strings the updater sees: core
* numbers first, then a release outranks its own prereleases, then the
* prerelease identifiers left to right. Unparseable input compares as
* equal so a junk version can never look like an update.
*/
export function compareAppVersions(left: string, right: string): number {
const parsedLeft = parseAppVersion(left);
const parsedRight = parseAppVersion(right);
if (!parsedLeft || !parsedRight) {
return 0;
}
for (let index = 0; index < 3; index += 1) {
const difference = parsedLeft.core[index] - parsedRight.core[index];
if (difference !== 0) {
return Math.sign(difference);
}
}
if (parsedLeft.prerelease.length === 0) {
return parsedRight.prerelease.length === 0 ? 0 : 1;
}
if (parsedRight.prerelease.length === 0) {
return -1;
}
const length = Math.max(
parsedLeft.prerelease.length,
parsedRight.prerelease.length
);
for (let index = 0; index < length; index += 1) {
const leftIdentifier = parsedLeft.prerelease[index];
const rightIdentifier = parsedRight.prerelease[index];
if (leftIdentifier === undefined) {
return -1;
}
if (rightIdentifier === undefined) {
return 1;
}
const difference = compareIdentifiers(leftIdentifier, rightIdentifier);
if (difference !== 0) {
return difference;
}
}
return 0;
}
/**
* Marker the main process puts into the release-notes rejection when a
* version has no published GitHub release. `ipcRenderer.invoke` strips every
* custom property off a rejection and wraps the message, so the renderer can
* only recognise the case by this text.
*/
export const APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER =
'Release notes were not found for';
export function buildAppUpdateReleaseNotesNotFoundMessage(
version: string | undefined
): string {
return `${APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER} ${version ?? 'latest release'}`;
}
export function isAppUpdateReleaseNotesNotFoundMessage(
message: string | null | undefined
): boolean {
return Boolean(
message?.includes(APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER)
);
}
@@ -1,5 +1,7 @@
import type { AppUpdateChannel } from './app-update-channel.util';
import type { SourceProbeContext, SourceHealthResult } from './source-health';
import type { XtreamConnectionFailure } from './xtream-connection-test';
import type { ZoomLevelAction } from './zoom-level.util';
import type {
CatchupDownloadMetadata,
DownloadRecoveryResult,
@@ -263,7 +265,20 @@ export interface ElectronBridgeAppUpdateStatus {
progress?: ElectronBridgeAppUpdateProgress;
error?: string;
supportedSelfUpdate: boolean;
/** Release page for the configured channel; opened for manual installs. */
manualDownloadUrl: string;
/** Channel the updater checks (`Settings.updateChannel`, mirrored). */
channel: AppUpdateChannel;
/**
* Channel the current verdict — `latestVersion`, `release`, a download
* in flight or finished — was produced on. Absent until the first
* check. Differs from `channel` after a channel change saved while a
* download was running or done: the download is kept, the new channel
* has not been checked yet.
*/
verdictChannel?: AppUpdateChannel;
/** Channel the running build came from, read off its version string. */
installedChannel: AppUpdateChannel;
}
export type ElectronBridgeAppUpdateReleaseNotesDirection = 'previous' | 'next';
@@ -714,6 +729,19 @@ export interface ElectronBridgeApi {
*/
toggleFullScreenWindow: () => Promise<ElectronBridgeWindowState>;
closeWindow: () => Promise<void>;
/**
* Zoom shortcuts (Cmd/Ctrl and +/−/0). Synchronous and preload-local:
* steps the FRAME-BOUND temporary zoom level through `webFrame` (never a
* main-process `webContents.setZoomLevel`, whose per-URL entry the app's
* `file://` path routing resets — issue #1109) by `stepZoomLevel`'s
* rules — one `ZOOM_LEVEL_STEP` inside `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`,
* a stored level already outside them never moved against the request —
* and returns the level now applied, which is therefore not itself
* guaranteed to be within the limits. Persistence needs nothing from the
* caller: the main process reads the live level back on close, quit and
* reload.
*/
adjustZoomLevel: (action: ZoomLevelAction) => number;
getWindowState: () => Promise<ElectronBridgeWindowState>;
onWindowStateChange: (
callback: (state: ElectronBridgeWindowState) => void
@@ -848,6 +876,8 @@ export interface ElectronBridgeApi {
url: string,
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
/** Native picker for a local XMLTV file; resolves null when cancelled. */
openEpgFileDialog: () => Promise<string | null>;
clearEpgData: () => Promise<ElectronBridgeResult>;
reconcileEpgSources: (urls: string[]) => Promise<ElectronBridgeResult>;
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
@@ -0,0 +1,80 @@
import {
classifyEpgSourceReference,
isLocalEpgSourceReference,
isRemoteEpgSourceUrl,
validateEpgSourceReferenceControl,
} from './epg-source-reference.util';
describe('classifyEpgSourceReference', () => {
it.each([
'http://epg.example.org/guide.xml',
'https://epg.example.org/guide.xml.gz?token=abc',
' HTTPS://epg.example.org/guide.xml ',
])('treats %s as remote', (value) => {
expect(classifyEpgSourceReference(value)).toBe('remote');
expect(isRemoteEpgSourceUrl(value)).toBe(true);
expect(isLocalEpgSourceReference(value)).toBe(false);
});
it.each([
'file:///home/user/epg/guide.xml.gz',
'file:///C:/epg/guide.xml',
'/home/user/epg/guide.xml',
'/Users/me/Downloads/guide.xml.gz',
'C:\\epg\\guide.xml.gz',
'd:/epg/guide.xml',
'\\\\nas\\share\\epg\\guide.xml.gz',
])('treats %s as a local file', (value) => {
expect(classifyEpgSourceReference(value)).toBe('local');
expect(isLocalEpgSourceReference(value)).toBe(true);
expect(isRemoteEpgSourceUrl(value)).toBe(false);
});
it.each([
'guide.xml',
'./guide.xml',
'~/epg/guide.xml',
'ftp://epg.example.org/guide.xml',
'file://',
'epg.example.org/guide.xml',
'C:',
'',
' ',
])('rejects %j', (value) => {
expect(classifyEpgSourceReference(value)).toBeNull();
});
it('rejects null and undefined', () => {
expect(classifyEpgSourceReference(null)).toBeNull();
expect(classifyEpgSourceReference(undefined)).toBeNull();
});
});
describe('validateEpgSourceReferenceControl', () => {
it('accepts an empty control like Validators.pattern does', () => {
expect(validateEpgSourceReferenceControl({ value: '' })).toBeNull();
expect(validateEpgSourceReferenceControl({ value: null })).toBeNull();
});
it('accepts remote links and local files', () => {
expect(
validateEpgSourceReferenceControl({
value: 'https://epg.example.org/guide.xml',
})
).toBeNull();
expect(
validateEpgSourceReferenceControl({ value: '/tmp/guide.xml.gz' })
).toBeNull();
});
it('flags relative paths and unsupported schemes', () => {
expect(
validateEpgSourceReferenceControl({ value: 'guide.xml' })
).toEqual({ epgSourceReference: true });
expect(
validateEpgSourceReferenceControl({
value: 'ftp://epg.example.org/guide.xml',
})
).toEqual({ epgSourceReference: true });
});
});
@@ -0,0 +1,72 @@
/**
* Classification of the value a user types as an EPG source.
*
* A source is either a remote XMLTV link (`http:`/`https:`) or a local file:
* a `file:` URL, an absolute POSIX path, a Windows drive path or a UNC path.
* Relative paths are refused on purpose — the main process has no meaningful
* working directory to resolve them against.
*
* Only values the user entered by hand (Settings → EPG, the playlist dialog)
* may be local. URLs harvested from an M3U header are filtered to remote ones
* before they are stored, so a downloaded playlist can never point the EPG
* importer at a file on the user's disk.
*/
export type EpgSourceReferenceKind = 'remote' | 'local';
export const EPG_SOURCE_REFERENCE_ERROR = 'epgSourceReference';
const REMOTE_EPG_SOURCE_PATTERN = /^https?:\/\/[^\s"]+$/i;
const FILE_URL_EPG_SOURCE_PATTERN = /^file:\/\/[^\s"]+$/i;
const POSIX_ABSOLUTE_PATH_PATTERN = /^\/[^\0]+$/;
const WINDOWS_DRIVE_PATH_PATTERN = /^[a-z]:[\\/][^\0]*$/i;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\/\0]+\\[^\\/\0]+/;
export function classifyEpgSourceReference(
value: string | null | undefined
): EpgSourceReferenceKind | null {
const trimmed = value?.trim() ?? '';
if (trimmed === '') {
return null;
}
if (REMOTE_EPG_SOURCE_PATTERN.test(trimmed)) {
return 'remote';
}
if (
FILE_URL_EPG_SOURCE_PATTERN.test(trimmed) ||
POSIX_ABSOLUTE_PATH_PATTERN.test(trimmed) ||
WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) ||
WINDOWS_UNC_PATH_PATTERN.test(trimmed)
) {
return 'local';
}
return null;
}
export function isRemoteEpgSourceUrl(
value: string | null | undefined
): boolean {
return classifyEpgSourceReference(value) === 'remote';
}
export function isLocalEpgSourceReference(
value: string | null | undefined
): boolean {
return classifyEpgSourceReference(value) === 'local';
}
/**
* Form validator shared by the Settings EPG list and the playlist dialog.
* Typed structurally so this contracts library stays Angular-free; an empty
* control is valid, mirroring `Validators.pattern`.
*/
export function validateEpgSourceReferenceControl(control: {
value: unknown;
}): Record<typeof EPG_SOURCE_REFERENCE_ERROR, true> | null {
const value = control.value;
if (typeof value !== 'string' || value.trim() === '') {
return null;
}
return classifyEpgSourceReference(value)
? null
: { [EPG_SOURCE_REFERENCE_ERROR]: true };
}
@@ -140,6 +140,11 @@ export const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
export const WINDOW_CLOSE = 'WINDOW:CLOSE';
export const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
// Synchronous preload request for the persisted app zoom level (answered
// with `event.returnValue`), and the preload's acknowledgement once it has
// applied that level with webFrame.setZoomLevel at DOMContentLoaded
export const WINDOW_GET_ZOOM_LEVEL = 'WINDOW:GET_ZOOM_LEVEL';
export const WINDOW_ZOOM_LEVEL_APPLIED = 'WINDOW:ZOOM_LEVEL_APPLIED';
// Close guard: while active, closing/quitting the app is intercepted in the
// main process and handed to the renderer for a save/discard/stay decision
@@ -0,0 +1,92 @@
import { buildPlaylistRecentItems } from './playlist-recently-viewed.utils';
import { resolvePortalActivityWatchKind } from './portal-activity-item.interface';
import type { PlaylistMeta } from './playlist-meta.type';
const labels = { stalker: 'Stalker', m3u: 'M3U' };
function stalkerPlaylist(recentlyViewed: unknown[]): PlaylistMeta {
return {
_id: 'stalker-1',
title: 'Portal',
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:1A:79:00:00:01',
recentlyViewed,
} as unknown as PlaylistMeta;
}
describe('buildPlaylistRecentItems (Stalker watch kind)', () => {
it('keeps embedded-VOD shows routing as movies while tracking them as series', () => {
// Real stored shape: no `is_series`, numeric VOD category, and the
// `series[]` episode array the portal answered with.
const [item] = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '17572',
title: 'Fake (10 episodes)',
category_id: '7',
series: [1, 2, 3],
added_at: '2026-09-19T16:13:50.000Z',
},
]),
],
labels
);
expect(item.type).toBe('movie');
expect(item.watch_kind).toBe('series');
expect(resolvePortalActivityWatchKind(item)).toBe('series');
});
it('marks lazy Ministra is_series rows as series-tracked too', () => {
const [item] = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '50001',
title: 'Flagged',
category_id: 'vod',
is_series: '1',
added_at: '2026-09-19T16:13:50.000Z',
},
]),
],
labels
);
expect(item.type).toBe('series');
expect(item.watch_kind).toBe('series');
});
it('leaves plain movies and live channels without a watch kind override', () => {
const items = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '900',
title: 'A Movie',
category_id: '7',
series: [],
added_at: '2026-09-19T16:13:50.000Z',
},
{
id: '712',
title: 'Channel',
category_id: 'itv',
added_at: '2026-09-19T14:49:22.000Z',
},
]),
],
labels
);
expect(items.map((item) => item.watch_kind)).toEqual([
undefined,
undefined,
]);
expect(resolvePortalActivityWatchKind(items[0])).toBe('movie');
expect(resolvePortalActivityWatchKind(items[1])).toBeNull();
});
});
@@ -6,6 +6,7 @@ import {
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
isStalkerSeriesItem,
normalizeStalkerDate,
} from './stalker-item.normalizer';
import {
@@ -59,6 +60,10 @@ function mapStalkerPlaylistRecentItems(
backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl,
source: 'stalker',
stalker_item: rawItem,
// `type` stays the routing verdict: an embedded-VOD row
// (a `series[]` array, no flag) routes as a movie. Either
// shape tracks progress per episode, keyed by this parent id.
...(isStalkerSeriesItem(item) ? { watch_kind: 'series' } : {}),
});
return acc;
@@ -47,6 +47,43 @@ export interface PortalActivityItem {
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
/**
* How the item's watch progress is tracked, when that differs from the
* catalog section `type` routes it to.
*
* The case that needs it is a Stalker embedded-VOD row: its stored entry
* announces episodes through a `series[]` array, but `extractStalkerItemType`
* is deliberately blind to that array so the item keeps routing to the VOD
* catalog, and it therefore reports `type: 'movie'` while its progress is a
* set of EPISODE positions keyed by the parent id. (A lazy Ministra
* `is_series` row already reports `series` — the flag is read — so there
* this field only restates the type.)
*
* Readers that decide between a movie position and a series position go
* through `resolvePortalActivityWatchKind`, never `type` alone. Absent
* means "same as `type`".
*/
watch_kind?: PortalActivityWatchKind;
}
/** Progress model of a VOD activity item: one position, or per-episode positions. */
export type PortalActivityWatchKind = 'movie' | 'series';
/**
* The progress model a dashboard/collection reader should use for an item:
* the explicit `watch_kind` when a mapper recorded one, else the catalog
* `type`. Live items have no progress and resolve to `null`.
*/
export function resolvePortalActivityWatchKind(
item: Pick<PortalActivityItem, 'type' | 'watch_kind'>
): PortalActivityWatchKind | null {
if (item.watch_kind === 'movie' || item.watch_kind === 'series') {
return item.watch_kind;
}
if (item.type === 'movie' || item.type === 'series') {
return item.type;
}
return null;
}
/** A recently-viewed item with a `viewed_at` timestamp. */
@@ -0,0 +1,85 @@
import {
RENDERER_RESTORE_ROUTE_QUERY_PARAM,
resolveRestoredRendererRoute,
} from './renderer-reload-route.util';
const rendererRoot = 'file:///Applications/IPTVnator.app/Contents/web/';
const packagedIndex = `${rendererRoot}index.html`;
function reloadedIndex(route: string): string {
const url = new URL(packagedIndex);
url.searchParams.set(RENDERER_RESTORE_ROUTE_QUERY_PARAM, route);
return url.href;
}
describe('resolveRestoredRendererRoute', () => {
it('leaves a document without a restore request alone', () => {
expect(resolveRestoredRendererRoute(packagedIndex, packagedIndex)).toBe(
null
);
expect(
resolveRestoredRendererRoute(
`${packagedIndex}?other=1`,
packagedIndex
)
).toBe(null);
});
it('restores a route relative to the renderer directory', () => {
expect(
resolveRestoredRendererRoute(
reloadedIndex('workspace/sources'),
packagedIndex
)
).toBe(`${rendererRoot}workspace/sources`);
});
it('keeps the restored route query and fragment', () => {
expect(
resolveRestoredRendererRoute(
reloadedIndex('workspace/xtreams/3/search?q=dune#top'),
packagedIndex
)
).toBe(`${rendererRoot}workspace/xtreams/3/search?q=dune#top`);
});
it('resolves against the base URI, not the document URL', () => {
// The packaged <base href="./"> resolves to the renderer directory
// even when the document itself sits deeper.
expect(
resolveRestoredRendererRoute(
`${rendererRoot}workspace/sources?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fdashboard`,
rendererRoot
)
).toBe(`${rendererRoot}workspace/dashboard`);
});
it('works for an http origin with a root base href', () => {
expect(
resolveRestoredRendererRoute(
`http://localhost:4200/?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fsettings%2Fplayback`,
'http://localhost:4200/'
)
).toBe('http://localhost:4200/workspace/settings/playback');
});
it.each([
['an absolute URL', 'https://example.com/phish'],
['another scheme', 'javascript:alert(1)'],
['a directory escape', '../../etc/passwd'],
['a root-absolute path outside the renderer', '/etc/passwd'],
['a scheme-relative URL', '//example.com/'],
['the renderer directory itself', './'],
['an empty route', ''],
])('drops %s and only removes the parameter', (_label, route) => {
expect(
resolveRestoredRendererRoute(reloadedIndex(route), packagedIndex)
).toBe(packagedIndex);
});
it('returns null for an unparsable document URL', () => {
expect(resolveRestoredRendererRoute('not a url', packagedIndex)).toBe(
null
);
});
});
@@ -0,0 +1,72 @@
/**
* Route hand-off for a reloaded packaged renderer.
*
* The packaged renderer is loaded from `dist/apps/web/index.html` over
* `file://` and Angular uses PATH routing, so after in-app navigation the
* document URL is `file:///…/web/workspace/sources` — a path with no file
* behind it. A reload (the macOS View › Reload menu, DevTools, the settings
* unsaved-changes guard's confirmed reload) therefore fails with
* `ERR_FILE_NOT_FOUND` and strands the window on Chromium's error page.
*
* The Electron main process recovers such a failure by loading `index.html`
* again with the failed URL's route (its path relative to the renderer root,
* plus query and fragment) carried in this query parameter. The renderer
* consumes it before Angular bootstraps: `resolveRestoredRendererRoute`
* turns the current document URL into the in-app URL the router should
* start from, and `main.ts` installs it with `history.replaceState`, so the
* router's initial navigation lands on the route the user was on.
*/
/** Query parameter carrying the route to restore on the reloaded index. */
export const RENDERER_RESTORE_ROUTE_QUERY_PARAM = 'restoreRoute';
/**
* The URL to present instead of `currentHref` before the router's initial
* navigation, or `null` when the document carries no restore request.
*
* `baseUri` is `document.baseURI`: the packaged build's `<base href="./">`
* resolves to the renderer's directory, which is also what Angular strips
* from `location.pathname` to obtain the route. A route that would leave
* that directory — an absolute URL, another scheme, a `..` escape — is
* dropped and only the parameter is removed, so the app boots at its
* default route rather than following an arbitrary target.
*/
export function resolveRestoredRendererRoute(
currentHref: string,
baseUri: string
): string | null {
let current: URL;
let baseDirectory: URL;
try {
current = new URL(currentHref);
baseDirectory = new URL('./', baseUri);
} catch {
return null;
}
const route = current.searchParams.get(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
if (route === null) {
return null;
}
current.searchParams.delete(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
const stripped = current.href;
let target: URL;
try {
target = new URL(route, baseDirectory);
} catch {
return stripped;
}
const staysInsideRenderer =
target.protocol === baseDirectory.protocol &&
target.host === baseDirectory.host &&
target.pathname.startsWith(baseDirectory.pathname) &&
target.pathname !== baseDirectory.pathname;
return staysInsideRenderer ? target.href : stripped;
}
@@ -0,0 +1,58 @@
import { foldSearchText } from './search-text-fold.util';
describe('foldSearchText', () => {
it('folds the Turkish dotted capital I onto a plain lower-case i (issue #609)', () => {
expect(foldSearchText('İnşaat Kanalı')).toBe('inşaat kanalı');
expect(
foldSearchText('İnşaat Kanalı').includes(foldSearchText('inş'))
).toBe(true);
expect(foldSearchText('İnş')).toBe(foldSearchText('inş'));
});
it.each([
['Ünlü Şef', 'ünlü şef'],
['ÇANAKKALE', 'çanakkale'],
['Первый канал HD', 'первый канал hd'],
['Ёжик', 'ёжик'],
['Йога для всех', 'йога для всех'],
['Ελλάδα Σήμερα', 'ελλάδα σήμερα'],
['Amélie', 'amélie'],
['Straße', 'straße'],
])(
'keeps precomposed letters of other scripts intact: %s',
(input, expected) => {
expect(foldSearchText(input)).toBe(expected);
}
);
it.each([
['Amélie', 'Amélie'],
['İnşaat', 'İnşaat'],
['Ёжик', 'Ёжик'],
['Йога', 'Йога'],
['Ünlü', 'Ünlü'],
['Ά', 'Ά'],
])(
'folds canonically equivalent spellings of %s to one string',
(precomposed, decomposed) => {
expect(foldSearchText(decomposed)).toBe(
foldSearchText(precomposed)
);
}
);
it('stays accent-sensitive, unlike the diacritic-folding SQL index', () => {
// The renderer filters were accent-sensitive before this helper and
// stay so: only marks that cannot compose are dropped. Accent-blind
// matching is the FTS index's own behaviour, not this fold's.
expect(foldSearchText('Amélie')).not.toBe(foldSearchText('Amelie'));
});
it('does not fold the dotless ı onto i', () => {
// The Turkish I/ı pair is a separate letter, not a case form of i;
// it is deliberately left alone (see the FTS index, which does not
// fold it either).
expect(foldSearchText('Işık')).toBe('işık');
expect(foldSearchText('ışık')).toBe('ışık');
});
});
@@ -0,0 +1,35 @@
/**
* Locale-invariant case fold for in-memory search filters (channel lists,
* catalog and category filters, source and download lists). Pure function,
* no Angular/Node dependencies.
*
* `toLowerCase()` alone is not enough for one visible input: the Turkish
* dotted capital "İ" (U+0130) lower-cases to "i" plus a combining dot above
* (U+0307), so `"İnşaat".toLowerCase().includes("inş")` is `false` even
* though the two spellings are the same word (issue #609).
*
* The fold therefore runs `toLowerCase()`, re-composes the result to NFC and
* drops whatever combining marks are left. Composing first is what makes the
* fold agree on canonically equivalent text: a provider title stored
* decomposed ("e" + U+0301) and a query typed precomposed ("é") reach the
* same string, and a Greek "Ά" lower-cases to a form NFC maps onto the same
* "ά" the user types. Only marks that cannot compose survive to be stripped —
* the dotted I's leftover dot among them — so precomposed letters (ç, ş, ü,
* é, ё, й) are preserved and the fold stays accent-sensitive, exactly as the
* filters were before. A lower-cased string of printable ASCII is already NFC
* and carries no marks, which is the fast path taken by most titles.
*
* `toLocaleLowerCase()` is deliberately not used: under a Turkish or Azeri
* OS locale it maps ASCII "I" to the dotless "ı", so the same list would
* filter differently per machine.
*/
const COMBINING_MARKS_REGEXP = /[̀-ͯ]/g;
const NON_PRINTABLE_ASCII_REGEXP = /[^ -~]/;
export function foldSearchText(value: string): string {
const lowered = value.toLowerCase();
return NON_PRINTABLE_ASCII_REGEXP.test(lowered)
? lowered.normalize('NFC').replace(COMBINING_MARKS_REGEXP, '')
: lowered;
}
@@ -1,3 +1,4 @@
import type { AppUpdateChannel } from './app-update-channel.util';
import { Language } from './language.enum';
import { StreamFormat } from './stream-format.enum';
import { Theme } from './theme.enum';
@@ -189,6 +190,15 @@ export interface Settings {
* value means `'normal'`. F11 leaves or re-enters fullscreen.
*/
startupWindowMode?: StartupWindowMode;
/**
* Desktop update channel: `stable` (tagged releases) or `nightly`
* (prereleases published from every master merge). Electron only;
* a missing value means `stable`. Mirrored into the main-process config
* on Save so the startup update check already uses it. Forward-only:
* switching back to stable keeps the installed nightly until a newer
* stable release exists.
*/
updateChannel?: AppUpdateChannel;
/** Show the desktop footer bar for external playback status */
showExternalPlaybackBar?: boolean;
/** Strip country/group prefixes like "US | " or "UK - " from channel names */
@@ -241,6 +251,15 @@ export interface Settings {
portalConnectivityGuard?: boolean;
/** Cover/poster sizing preset applied across grids and rails */
coverSize?: CoverSize;
/**
* Titles under movie/series covers. Default on; only an explicit false
* switches VOD, series, favorites and recent grids to a posters-only
* wall whose title appears as an overlay on hover/focus. Live channel
* grids, search results and the dashboard rails always keep their
* labels: logos are often missing, a search answers by name, and rails
* carry meta rows the overlay cannot hold.
*/
showCoverTitles?: boolean;
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
epgViewMode?: EpgViewMode;
/**
@@ -1,4 +1,5 @@
import {
cleanTitleForSearch,
normalizeTitle,
normalizeTitleKeys,
titleYearsCompatible,
@@ -429,3 +430,81 @@ describe('titleYearsCompatible', () => {
expect(titleYearsCompatible(1982, 2049)).toBe(false);
});
});
describe('cleanTitleForSearch', () => {
it('keeps Cyrillic letters that folding would rewrite', () => {
// NFD splits "й" into "и" + a breve and "ё" into "е" + a diaeresis;
// the comparison key drops both marks, and TMDB answers that folded
// spelling with nothing. The titles here are illustrative stand-ins
// chosen to fold the same way.
expect(normalizeTitle('Лейка (10 серий)')).toBe('леика');
expect(cleanTitleForSearch('Лейка (10 серий)')).toBe('Лейка');
expect(cleanTitleForSearch('Ёжик 2010')).toBe('Ёжик');
expect(cleanTitleForSearch('Тестовый Сериал s02')).toBe(
'Тестовый Сериал'
);
});
it('keeps Arabic hamza forms that folding splits into two words', () => {
// "أ" decomposes into a bare alef + U+0654, which is outside the
// stripped mark range and so becomes a SPACE in the key — splitting
// the word in two, which is not a spelling TMDB indexes.
expect(normalizeTitle('AR| أمثلة تجريبية')).toBe('ا مثلة تجريبية');
expect(cleanTitleForSearch('AR| أمثلة تجريبية')).toBe('أمثلة تجريبية');
});
it('keeps Latin diacritics and casing', () => {
expect(cleanTitleForSearch('Amélie (2001)')).toBe('Amélie');
expect(cleanTitleForSearch('THE LAST OF US')).toBe('THE LAST OF US');
});
it('recomposes decomposed input and keeps marks attached to their letter', () => {
// Providers ship "Dönüş" as "o" + U+0308; the key folds it away but
// the query must not split the word on the combining mark.
expect(
cleanTitleForSearch(
'1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR'
)
).toBe('1942 ye Dönüş');
expect(
normalizeTitle('1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR')
).toBe('1942 ye donus');
// No precomposed form exists for "ọ̀": the mark stays on the letter
expect(cleanTitleForSearch('AR - Ìjọ̀gbọ̀n (2023)')).toBe('Ìjọ̀gbọ̀n');
expect(normalizeTitle('AR - Ìjọ̀gbọ̀n (2023)')).toBe('ijogbon');
});
it('applies the same tag, bracket, season and year stripping as the key', () => {
expect(cleanTitleForSearch('EN - The Matrix (1999) 4K')).toBe(
'The Matrix'
);
expect(cleanTitleForSearch('|DE| Breaking Bad-DE')).toBe(
'Breaking Bad'
);
expect(cleanTitleForSearch('The Boys s05')).toBe('The Boys');
expect(cleanTitleForSearch('Пацаны 2 сезон')).toBe('Пацаны');
expect(cleanTitleForSearch('The Matrix 1999')).toBe('The Matrix');
// A leading token that is the film's own name survives on both tiers
expect(cleanTitleForSearch('AKA - 2023')).toBe('AKA');
expect(normalizeTitle('AKA - 2023')).toBe('aka');
});
it('normalizes to the same key as the text it was derived from', () => {
for (const raw of [
'Лейка (10 серий)',
'EN - The Matrix (1999) 4K',
'Ёжик 2010',
'Amélie (2001)',
'|FR|VO|Le dernier empereur',
]) {
expect(normalizeTitle(cleanTitleForSearch(raw))).toBe(
normalizeTitle(raw)
);
}
});
it('returns an empty string for empty input', () => {
expect(cleanTitleForSearch('')).toBe('');
expect(cleanTitleForSearch(null)).toBe('');
});
});
@@ -302,13 +302,13 @@ const HAS_LETTER = /\p{L}/u;
* "2023" and were offered to each other as alternative sources. A miss beats
* a wrong match, so an unknown token keeps its title.
*/
function normalizeAfterLeadingTag(value: string): string {
function normalizeAfterLeadingTag(value: string, fold: boolean): string {
const match = value.match(LANGUAGE_PREFIX);
if (!match) {
return normalizeRest(value);
return normalizeRest(value, fold);
}
const stripped = normalizeRest(value.replace(LANGUAGE_PREFIX, ''));
const stripped = normalizeRest(value.replace(LANGUAGE_PREFIX, ''), fold);
// Deliberately not `stripSeason`: its "never return empty" fallback would
// report a lone season marker as a surviving word.
if (HAS_LETTER.test(stripped.replace(SEASON_SUFFIX_PATTERN, ''))) {
@@ -316,7 +316,7 @@ function normalizeAfterLeadingTag(value: string): string {
}
const token = match[0].replace(PREFIX_SEPARATOR_TAIL, '');
return isKnownPrefixTag(token) ? stripped : normalizeRest(value);
return isKnownPrefixTag(token) ? stripped : normalizeRest(value, fold);
}
const DOUBLE_DASH_SUFFIX = /[-–]{2}[A-Za-z]{2,5}\s*$/;
@@ -438,28 +438,49 @@ const SEASON_SUFFIX_PATTERN = new RegExp(
* actually produce instead of predicting it. Cheap enough to run twice,
* because the second run only happens for a title whose stripped form came
* out with no word in it at all.
*
* `fold` selects the letter folding (diacritics, case, sigma). It is ON for
* every comparison key and OFF for the text sent to a remote search — see
* `cleanTitleForSearch`. Folding is lossy for scripts whose "diacritics" are
* distinct letters: NFD turns Cyrillic "й" into "и" + a combining breve, and
* dropping the breve rewrites "Лейка" as "леика", "ё" as "е". Two provider
* copies of a title still meet on that key, which is all a comparison needs,
* but TMDB's search does not fold Cyrillic the same way and answers a folded
* query with nothing at all.
*/
function normalizeRest(value: string): string {
return (
stripTrailingTags(value)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
// Greek Σ has two lowercase forms and `toLowerCase` picks by
// position: "ΑΣ" becomes "ας" while an already-lowercase "ασ"
// stays medial, so the same word reaches this line spelled two
// ways. Both SQL tiers fold them together — SQLite's trigram
// tokenizer does it natively, and the scan's GLOB classes do it in
// `caseInsensitiveGlobPattern` — so without this the candidate is
// admitted by the query and then thrown away by the confirmation.
// Folding to the medial form is what Unicode case folding does.
.replace(/ς/g, 'σ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.split(' ')
.filter((token) => token !== '' && !QUALITY_TAGS.has(token))
.join(' ')
.trim()
);
function normalizeRest(value: string, fold: boolean): string {
const withoutTrailingTags = stripTrailingTags(value);
const folded = fold
? withoutTrailingTags
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
// Greek Σ has two lowercase forms and `toLowerCase` picks by
// position: "ΑΣ" becomes "ας" while an already-lowercase "ασ"
// stays medial, so the same word reaches this line spelled two
// ways. Both SQL tiers fold them together — SQLite's trigram
// tokenizer does it natively, and the scan's GLOB classes do
// it in `caseInsensitiveGlobPattern` — so without this the
// candidate is admitted by the query and then thrown away by
// the confirmation. Folding to the medial form is what Unicode
// case folding does.
.replace(/ς/g, 'σ')
: // Providers ship some titles decomposed ("o" + U+0308 for "ö").
// Recompose so the query reads as the provider meant it, and
// keep any mark that has no precomposed form ("ọ̀") attached to
// its letter instead of letting the word-splitting step below
// turn it into a space inside the word.
withoutTrailingTags.normalize('NFC');
const nonWord = fold ? /[^\p{L}\p{N}]+/gu : /[^\p{L}\p{N}\p{M}]+/gu;
return folded
.replace(nonWord, ' ')
.split(' ')
.filter(
(token) => token !== '' && !QUALITY_TAGS.has(token.toLowerCase())
)
.join(' ')
.trim();
}
/**
@@ -488,6 +509,28 @@ export interface NormalizedTitleKeys {
export function normalizeTitleKeys(
raw: string | null | undefined
): NormalizedTitleKeys {
return buildTitleKeys(raw, true);
}
/**
* The title to SEND to a remote search such as TMDB: the same tag, bracket,
* season and year stripping as `normalizeTitle`, but with the letters left
* exactly as the provider wrote them — no diacritic folding, no lowercasing.
*
* Comparison keys must fold so two spellings of one film meet; a search
* query must not, because the search engine folds by its own rules and a
* pre-folded Cyrillic query matches nothing there ("леика" for "Лейка"
* illustrates the rewrite).
* Compare the results with `normalizeTitle`, never with this.
*/
export function cleanTitleForSearch(raw: string | null | undefined): string {
return buildTitleKeys(raw, false).base;
}
function buildTitleKeys(
raw: string | null | undefined,
fold: boolean
): NormalizedTitleKeys {
if (!raw) {
return { exact: '', base: '', trailingYear: null };
@@ -498,7 +541,8 @@ export function normalizeTitleKeys(
.replace(WRAPPED_TAG_PREFIX, '')
// Inner classes exclude the opening delimiter too, so runaway
// inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos)
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' '),
fold
);
const exact = stripSeason(cleaned);
@@ -17,6 +17,13 @@ export interface XtreamSerieDetails {
* the provider's `seasons[].overview` is empty or URL-only junk.
*/
tmdb_season_overviews?: Record<string, string>;
/**
* Populated by lazy TMDB season enrichment; absent in raw provider
* responses. Keyed like `tmdb_season_overviews`. Full image URLs of
* each season's own TMDB poster, the first choice for the season
* cover in the detail view and the fullscreen episode panel.
*/
tmdb_season_posters?: Record<string, string>;
}
export interface XtreamSerieInfo {
@@ -0,0 +1,60 @@
import {
clampZoomLevel,
stepZoomLevel,
ZOOM_LEVEL_MAX,
ZOOM_LEVEL_MIN,
ZOOM_LEVEL_STEP,
} from './zoom-level.util';
describe('zoom level shortcuts contract', () => {
it('steps by the Electron menu-role step in both directions', () => {
expect(stepZoomLevel(0, 'in')).toBe(ZOOM_LEVEL_STEP);
expect(stepZoomLevel(0, 'out')).toBe(-ZOOM_LEVEL_STEP);
expect(stepZoomLevel(1, 'in')).toBe(1.5);
expect(stepZoomLevel(-1.5, 'out')).toBe(-2);
});
it('resets to the default level from anywhere', () => {
expect(stepZoomLevel(3, 'reset')).toBe(0);
expect(stepZoomLevel(-2.5, 'reset')).toBe(0);
expect(stepZoomLevel(Number.NaN, 'reset')).toBe(0);
});
it('clamps at the app limits, not at Chromium limits', () => {
expect(stepZoomLevel(ZOOM_LEVEL_MAX, 'in')).toBe(ZOOM_LEVEL_MAX);
expect(stepZoomLevel(ZOOM_LEVEL_MIN, 'out')).toBe(ZOOM_LEVEL_MIN);
expect(stepZoomLevel(ZOOM_LEVEL_MAX - 0.5, 'in')).toBe(ZOOM_LEVEL_MAX);
expect(clampZoomLevel(ZOOM_LEVEL_MIN - 1)).toBe(ZOOM_LEVEL_MIN);
});
it('never moves a stored out-of-range level against the request', () => {
// The macOS menu roles never clamped and the store restores any
// finite level, so an upgrade can start beyond the limits.
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 1, 'in')).toBe(
ZOOM_LEVEL_MAX + 1
);
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 1, 'out')).toBe(ZOOM_LEVEL_MAX);
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 0.3, 'out')).toBe(ZOOM_LEVEL_MAX);
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'out')).toBe(
ZOOM_LEVEL_MIN - 1
);
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'in')).toBe(ZOOM_LEVEL_MIN);
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'reset')).toBe(0);
});
it('snaps an off-grid level to the next grid point in the requested direction', () => {
// A level the macOS menu roles or an older store left between steps.
expect(stepZoomLevel(0.3, 'in')).toBe(0.5);
expect(stepZoomLevel(0.3, 'out')).toBe(0);
expect(stepZoomLevel(-0.7, 'out')).toBe(-1);
expect(stepZoomLevel(-0.7, 'in')).toBe(-0.5);
});
it('treats a non-finite level as the default', () => {
expect(stepZoomLevel(Number.NaN, 'in')).toBe(ZOOM_LEVEL_STEP);
expect(stepZoomLevel(Number.POSITIVE_INFINITY, 'out')).toBe(
-ZOOM_LEVEL_STEP
);
expect(clampZoomLevel(Number.NaN)).toBe(0);
});
});
@@ -0,0 +1,66 @@
/**
* App zoom shortcut contract (Cmd/Ctrl and +/−/0), shared by the renderer
* key binding (`WorkspaceKeyboardShortcutsService`) and the preload, which
* applies the result through `webFrame.setZoomLevel` (issue #1109: the level
* must stay a frame-bound temporary zoom; see
* `docs/architecture/workspace-shell.md`, "Zoom level").
*
* A Chromium zoom level is the exponent of a 1.2 factor: level 1 renders at
* 120 %, level −1 at ~83 %. The step matches Electron's `zoomIn`/`zoomOut`
* menu roles (0.5, about 10 % per press), so the level the macOS menu used
* to produce and the level the shortcuts produce are the same series.
*/
export type ZoomLevelAction = 'in' | 'out' | 'reset';
export const ZOOM_LEVEL_DEFAULT = 0;
/** Electron's `zoomIn`/`zoomOut` role step. */
export const ZOOM_LEVEL_STEP = 0.5;
/** ≈48 % — below this the workspace chrome is unreadable. */
export const ZOOM_LEVEL_MIN = -4;
/** ≈299 % — well inside Chromium's own 25–500 % limits. */
export const ZOOM_LEVEL_MAX = 6;
export function clampZoomLevel(level: number): number {
if (!Number.isFinite(level)) {
return ZOOM_LEVEL_DEFAULT;
}
return Math.min(ZOOM_LEVEL_MAX, Math.max(ZOOM_LEVEL_MIN, level));
}
/**
* The level `action` produces from `current`. Steps are snapped to the step
* grid so a level restored from an older store (or set by the macOS menu
* before the shortcuts existed) does not carry a fractional offset forever.
* A level already outside the limits — the macOS menu roles never clamped,
* and the store restores any finite level — is never moved AGAINST the
* request: a press further out leaves it where it is, a press back in lands
* on the limit. A non-finite `current` is treated as the default.
*/
export function stepZoomLevel(
current: number,
action: ZoomLevelAction
): number {
if (action === 'reset') {
return ZOOM_LEVEL_DEFAULT;
}
const base = Number.isFinite(current) ? current : ZOOM_LEVEL_DEFAULT;
const direction = action === 'in' ? 1 : -1;
if (direction > 0 ? base >= ZOOM_LEVEL_MAX : base <= ZOOM_LEVEL_MIN) {
return base;
}
const steps = base / ZOOM_LEVEL_STEP;
// A level between grid points steps to the next grid point in the
// requested direction instead of skipping past it.
const nextSteps = Number.isInteger(steps)
? steps + direction
: direction > 0
? Math.ceil(steps)
: Math.floor(steps);
return clampZoomLevel(nextSteps * ZOOM_LEVEL_STEP);
}
+2 -1
View File
@@ -8,7 +8,8 @@
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
"noPropertyAccessFromIndexSignature": true,
"ignoreDeprecations": "6.0"
},
"files": [],
"include": [],
+2 -1
View File
@@ -8,7 +8,8 @@
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
"noPropertyAccessFromIndexSignature": true,
"ignoreDeprecations": "6.0"
},
"files": [],
"include": [],
@@ -130,6 +130,21 @@ describe('playlist utils', () => {
]);
});
it('ignores local file references declared by the playlist header', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {
'x-tvg-url':
'file:///etc/passwd, https://example.com/guide.xml',
'url-tvg': '/home/user/epg/guide.xml.gz',
},
raw: '#EXTM3U x-tvg-url="file:///etc/passwd, https://example.com/guide.xml" url-tvg="/home/user/epg/guide.xml.gz"',
},
})
).toEqual(['https://example.com/guide.xml']);
});
it('falls back to the raw header for tvg-url variants the parser does not expose as attrs', () => {
expect(
extractM3uEpgUrls({
@@ -319,6 +334,50 @@ describe('playlist utils', () => {
});
});
it('drops legacy header-declared local references but keeps manual local files', () => {
expect(
resolvePlaylistEpgSourceState({
detectedEpgUrls: [
'file:///etc/passwd',
'https://playlist.example.com/auto.xml',
],
enabledEpgUrls: [
'file:///etc/passwd',
'https://playlist.example.com/auto.xml',
],
manualEpgUrls: ['/home/user/epg/guide.xml.gz'],
})
).toEqual({
detectedEpgUrls: ['https://playlist.example.com/auto.xml'],
epgUrls: [
'https://playlist.example.com/auto.xml',
'/home/user/epg/guide.xml.gz',
],
manualEpgUrls: ['/home/user/epg/guide.xml.gz'],
disabledEpgUrls: [],
});
});
it('never fetches a stored local reference the user did not add by hand', () => {
expect(
filterPlaylistEpgUrlsForFetch(
[
'file:///etc/passwd',
'/home/user/epg/guide.xml.gz',
'https://playlist.example.com/auto.xml',
],
[],
['/home/user/epg/guide.xml.gz']
)
).toEqual([
'/home/user/epg/guide.xml.gz',
'https://playlist.example.com/auto.xml',
]);
expect(
filterPlaylistEpgUrlsForFetch(['/home/user/epg/guide.xml.gz'], [])
).toEqual([]);
});
it('filters playlist EPG fetch URLs that are already configured globally', () => {
expect(
filterPlaylistEpgUrlsForFetch(
@@ -1,6 +1,7 @@
import {
Channel,
createRandomId,
isRemoteEpgSourceUrl,
ParsedPlaylist,
ParsedPlaylistItem,
Playlist,
@@ -67,7 +68,11 @@ export const getFilenameFromUrl = (value: string): string => {
};
const M3U_EPG_HEADER_ATTRS = ['x-tvg-url', 'url-tvg', 'tvg-url'] as const;
const M3U_EPG_URL_PATTERN = /\b(?:https?|file):\/\/[^\s,"']+/gi;
// Header-declared sources are provider-controlled input. Only remote links
// are harvested: a `file:` URL here would let a downloaded playlist point the
// EPG importer at a file on the user's disk. Local XMLTV files are supported,
// but only when the user types the path in Settings or the playlist dialog.
const M3U_EPG_URL_PATTERN = /\bhttps?:\/\/[^\s,"']+/gi;
export const M3U_AUTO_IMPORT_EPG_URL_LIMIT = 5;
export const M3U_RECOMMENDED_EPG_URL_LIMIT = 12;
@@ -105,14 +110,25 @@ export function normalizeEpgUrls(
export function resolvePlaylistEpgSourceState(
input: PlaylistEpgSourceStateInput
): PlaylistEpgSourceState {
const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls);
const manualEpgUrls = normalizeEpgUrls(input.manualEpgUrls);
const manualSet = new Set(manualEpgUrls);
// Versions before local-file support stored whatever the M3U header
// declared, `file://` included. Header-declared entries are provider
// input, so a stored non-remote one is dropped unless the user also added
// it by hand — only hand-added sources may point at a local file.
const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls).filter(
isRemoteEpgSourceUrl
);
const disabledEpgUrls = normalizeEpgUrls(input.disabledEpgUrls);
const disabledSet = new Set(disabledEpgUrls);
const epgUrls = normalizeEpgUrls([
...normalizeEpgUrls(input.enabledEpgUrls),
...manualEpgUrls,
]).filter((url) => !disabledSet.has(url));
]).filter(
(url) =>
!disabledSet.has(url) &&
(isRemoteEpgSourceUrl(url) || manualSet.has(url))
);
return {
detectedEpgUrls,
@@ -122,13 +138,22 @@ export function resolvePlaylistEpgSourceState(
};
}
/**
* Playlist-scoped URLs worth fetching: not already configured globally, and
* never a stored non-remote entry unless it is in `manualEpgUrls` (see
* `resolvePlaylistEpgSourceState` for why legacy header values are dropped).
*/
export function filterPlaylistEpgUrlsForFetch(
playlistEpgUrls: readonly string[] | undefined,
globalEpgUrls: readonly string[] | undefined
globalEpgUrls: readonly string[] | undefined,
manualEpgUrls: readonly string[] | undefined = []
): string[] {
const globalUrlSet = new Set(normalizeEpgUrls(globalEpgUrls));
const manualSet = new Set(normalizeEpgUrls(manualEpgUrls));
return normalizeEpgUrls(playlistEpgUrls).filter(
(url) => !globalUrlSet.has(url)
(url) =>
!globalUrlSet.has(url) &&
(isRemoteEpgSourceUrl(url) || manualSet.has(url))
);
}
+2 -1
View File
@@ -8,7 +8,8 @@
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
"noPropertyAccessFromIndexSignature": true,
"ignoreDeprecations": "6.0"
},
"files": [],
"include": [],
+2 -1
View File
@@ -8,7 +8,8 @@
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
"noPropertyAccessFromIndexSignature": true,
"ignoreDeprecations": "6.0"
},
"files": [],
"include": [],