mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
Merge origin/master into PR 955
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user