Merge origin/master into PR 955

This commit is contained in:
4gray committed 2026-05-22 01:34:56 +03:00
commit 2a769ad44e
89 files changed
+6476 -465

No files matched your search

@@ -22,6 +22,7 @@ describe('database schema statements', () => {
createTableStatements,
columnMigrationStatements,
indexMigrationStatements,
normalizeXtreamContentAddedEpochs,
} = __databaseConnectionTestHooks;
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
@@ -117,4 +118,56 @@ describe('database schema statements', () => {
expect(objectNames).toHaveLength(new Set(objectNames).size);
});
it('runs the legacy millisecond Xtream timestamp normalization once', () => {
const selectGet = jest.fn().mockReturnValue(undefined);
const updateRun = jest.fn();
const stateRun = jest.fn();
const prepare = jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return { get: selectGet };
}
if (statement.includes('UPDATE content')) {
return { run: updateRun };
}
if (statement.includes('INSERT INTO app_state')) {
return { run: stateRun };
}
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
});
const transaction = jest.fn((callback: () => void) => callback);
const sqlite = {
prepare,
transaction,
} as unknown as Parameters<typeof normalizeXtreamContentAddedEpochs>[0];
normalizeXtreamContentAddedEpochs(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(updateRun).toHaveBeenCalledWith(10_000_000_000, 10_000_000_000);
expect(stateRun).toHaveBeenCalledWith(
'migration:xtream-content-added-epoch-seconds:v1'
);
});
it('skips legacy Xtream timestamp normalization after it has run', () => {
const selectGet = jest.fn().mockReturnValue({ value: 'done' });
const prepare = jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return { get: selectGet };
}
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
});
const transaction = jest.fn((callback: () => void) => callback);
const sqlite = {
prepare,
transaction,
} as unknown as Parameters<typeof normalizeXtreamContentAddedEpochs>[0];
normalizeXtreamContentAddedEpochs(sqlite);
expect(transaction).not.toHaveBeenCalled();
});
});
+61 -1
View File
@@ -23,6 +23,10 @@ let db: DatabaseInstance | null = null;
let sqlite: Database.Database | null = null;
let initPromise: Promise<DatabaseInstance> | null = null;
const XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000;
const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY =
'migration:xtream-content-added-epoch-seconds:v1';
function readTraceFlag(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
return value ? TRACE_ENV_TRUE_VALUES.has(value) : false;
@@ -298,6 +302,7 @@ export const __databaseConnectionTestHooks = {
createTableStatements: CREATE_TABLE_STATEMENTS,
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
normalizeXtreamContentAddedEpochs,
} as const;
/**
@@ -437,7 +442,9 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void {
const deleteRecentlyViewed = sqliteDb.prepare(
`DELETE FROM recently_viewed WHERE content_id = ?`
);
const deleteContent = sqliteDb.prepare(`DELETE FROM content WHERE id = ?`);
const deleteContent = sqliteDb.prepare(
`DELETE FROM content WHERE id = ?`
);
for (const group of duplicateContentGroups) {
const candidates = selectContentCandidates.all(
@@ -464,6 +471,58 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void {
executeCleanup();
}
function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
if (migrationState?.value === 'done') {
return;
}
const executeMigration = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`UPDATE content
SET added = CAST(CAST(added AS INTEGER) / 1000 AS TEXT)
WHERE added IS NOT NULL
AND added <> ''
AND added NOT GLOB '*[^0-9]*'
AND CAST(added AS INTEGER) >= ?
AND CAST(added AS INTEGER) / 1000 < ?`
)
.run(
XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD,
XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD
);
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(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY);
});
executeMigration();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Xtream added timestamp normalization failed (continuing): ${message}`
);
}
}
function runMigrationStatements(
sqliteDb: Database.Database,
statements: string[]
@@ -498,6 +557,7 @@ function runMigrationStatements(
function runMigrations(sqliteDb: Database.Database): void {
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
deduplicateXtreamCache(sqliteDb);
normalizeXtreamContentAddedEpochs(sqliteDb);
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
}
+1
View File
@@ -36,6 +36,7 @@ export * from './lib/xtream-item.interface';
export * from './lib/xtream-live-stream.interface';
export * from './lib/xtream-response.interface';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-recently-added.utils';
export * from './lib/xtream-serie-details.interface';
export * from './lib/xtream-serie-item.interface';
export * from './lib/xtream-vod-details.interface';
@@ -0,0 +1,28 @@
import {
getXtreamRecentlyAddedMaxEpochSeconds,
toXtreamRecentlyAddedTimestamp,
} from './xtream-recently-added.utils';
describe('Xtream recently-added timestamp helpers', () => {
const now = Date.parse('2026-05-19T00:00:00.000Z');
it('treats far-future provider timestamps as invalid for recent ranking', () => {
expect(toXtreamRecentlyAddedTimestamp('1893456000', now)).toBe(0);
});
it('normalizes valid epoch seconds to milliseconds', () => {
expect(toXtreamRecentlyAddedTimestamp('1779062400', now)).toBe(
Date.parse('2026-05-18T00:00:00.000Z')
);
});
it('treats the epoch unit threshold itself as milliseconds', () => {
expect(toXtreamRecentlyAddedTimestamp('10000000000', now)).toBe(
10_000_000_000
);
});
it('builds the query upper-bound with the same future grace window', () => {
expect(getXtreamRecentlyAddedMaxEpochSeconds(now)).toBe('1779235200');
});
});
@@ -0,0 +1,60 @@
const EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000;
export const XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS = 24 * 60 * 60 * 1000;
function normalizeEpochNumber(value: number): number {
return value >= EPOCH_MILLISECONDS_THRESHOLD ? value : value * 1000;
}
function parseTimestampMs(value: unknown): number {
if (typeof value === 'number') {
return normalizeEpochNumber(value);
}
if (typeof value !== 'string') {
return 0;
}
const trimmed = value.trim();
if (!trimmed) {
return 0;
}
if (/^\d+$/.test(trimmed)) {
return normalizeEpochNumber(Number(trimmed));
}
return 0;
}
export function toXtreamRecentlyAddedTimestamp(
value: unknown,
nowMs = Date.now()
): number {
const timestampMs = parseTimestampMs(value);
if (!Number.isFinite(timestampMs) || timestampMs <= 0) {
return 0;
}
if (timestampMs > nowMs + XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS) {
return 0;
}
return timestampMs;
}
export function toXtreamRecentlyAddedEpochSeconds(
value: unknown,
nowMs = Date.now()
): string {
const timestampMs = toXtreamRecentlyAddedTimestamp(value, nowMs);
return timestampMs > 0 ? String(Math.floor(timestampMs / 1000)) : '';
}
export function getXtreamRecentlyAddedMaxEpochSeconds(
nowMs = Date.now()
): string {
return String(
Math.floor((nowMs + XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS) / 1000)
);
}