feat: split live analysis actions and page catalog loading
Separate incremental live checks from full catalog rebuilds and page projected channel/variant loading so first live-list display stays bounded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -256,6 +256,7 @@ test('user analysis click reloads persisted languages without a browser-side Lay
|
||||
window.document.getElementById('live-analysis-button').click();
|
||||
assert.equal(rebuildClicked, 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
channelList.expandedVariants.add('sports one');
|
||||
channelList.expandAll();
|
||||
window.clearInterval(channelList._epgRefreshTimer);
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
@ -441,6 +442,7 @@ test('usable Laya language labels do not leave visible variants as unknown', asy
|
||||
|
||||
await channelList.loadChannels();
|
||||
await channelList.refreshLayaLanguages('laya');
|
||||
channelList.expandedVariants.add('sports one');
|
||||
channelList.expandAll();
|
||||
|
||||
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
||||
|
||||
@ -19,6 +19,23 @@ test('projection rebuild route requests a complete analysis', () => {
|
||||
assert.match(routeSource, /liveProjection\.startRebuild\(null, \{ analyzeAll: true \}\)/);
|
||||
});
|
||||
|
||||
test('live channels route accepts pagination and forwards page bounds to the projection service', () => {
|
||||
assert.ok(/req\.query\.page/.test(routeSource), 'GET /channels should parse req.query.page');
|
||||
assert.ok(/req\.query\.limit/.test(routeSource), 'GET /channels should parse req.query.limit');
|
||||
assert.ok(/getChannelPage\(\{[^}]*page[^}]*limit/s.test(routeSource), 'GET /channels should forward page and limit to getChannelPage');
|
||||
});
|
||||
|
||||
test('live variants route is paginated and authenticated', () => {
|
||||
assert.ok(/router\.get\('\/channels\/:logicalChannelId\/variants', requireAuth,/.test(routeSource), 'GET /channels/:logicalChannelId/variants should exist and require auth');
|
||||
assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*page[^}]*limit/s.test(routeSource), 'variants route should call getChannelVariants with page and limit');
|
||||
});
|
||||
|
||||
test('projection check-new route exists separately from complete rebuild', () => {
|
||||
assert.ok(/router\.post\('\/projection\/check-new', requireAuth, requireAdmin,/.test(routeSource), 'POST /projection/check-new should exist and require admin');
|
||||
assert.ok(/liveProjection\.(?:checkNewStreams|startIncrementalAnalysis|analyzeNewStreams)/.test(routeSource), 'check-new route should call an incremental analysis helper');
|
||||
assert.ok(!/\/projection\/check-new'[\s\S]*analyzeAll: true/.test(routeSource), 'check-new route must not request analyzeAll');
|
||||
});
|
||||
|
||||
test('projection authorization distinguishes viewer and admin roles', () => {
|
||||
assert.equal(isAdmin({ role: 'viewer' }), false);
|
||||
assert.equal(isAdmin({ role: 'admin' }), true);
|
||||
|
||||
@ -327,7 +327,8 @@ test('full catalog listing skips synchronous per-channel enrichment', () => {
|
||||
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
||||
});
|
||||
|
||||
assert.equal(page.channels.length, 500);
|
||||
assert.equal(page.channels.length, 100);
|
||||
assert.deepEqual(page.page, { page: 1, limit: 100, offset: 0, total: 500, hasMore: true, nextOffset: 100 });
|
||||
assert.equal(standardCalls, 0);
|
||||
assert.equal(epgCalls, 0);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
@ -342,6 +343,8 @@ test('broad searches above the enrichment guard remain bounded', () => {
|
||||
iptvOrgCatalog: { findMatch: () => { calls += 1; return null; } },
|
||||
epgMatching: { findBestMatch: () => { calls += 1; return null; } }
|
||||
});
|
||||
assert.equal(page.channels.length, 300);
|
||||
assert.equal(page.channels.length, 100);
|
||||
assert.equal(page.page.total, 300);
|
||||
assert.equal(page.page.hasMore, true);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
125
test/t37LiveListPerformanceRegression.test.js
Normal file
125
test/t37LiveListPerformanceRegression.test.js
Normal file
@ -0,0 +1,125 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
const {
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
|
||||
function createProjectionDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedProjectedChannel(db, index) {
|
||||
const logicalId = `logical:${index}`;
|
||||
db.prepare(`
|
||||
INSERT INTO logical_channels
|
||||
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, 1, 1)
|
||||
`).run(logicalId, `channel ${index}`, `Channel ${index}`, `1:${index}`);
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, ?, ?, 'HD', 2, 0, 'en', 1, 'laya', ?)
|
||||
`).run(
|
||||
`1:${index}`,
|
||||
String(index),
|
||||
`Channel ${index}`,
|
||||
`https://streams.test/${index}.m3u8`,
|
||||
logicalId,
|
||||
`channel ${index}`,
|
||||
`group-hash-${index}`,
|
||||
`language-hash-${index}`
|
||||
);
|
||||
}
|
||||
|
||||
test('/api/live/channels projection response is bounded for an oversized catalog', () => {
|
||||
const db = createProjectionDb();
|
||||
for (let index = 0; index < 1500; index++) seedProjectedChannel(db, index);
|
||||
|
||||
try {
|
||||
const result = liveProjection.getChannelPage({ includeHidden: false, userId: 7 }, db, {
|
||||
iptvOrgCatalog: { findMatch: () => null },
|
||||
epgMatching: { findBestMatch: () => null }
|
||||
});
|
||||
assert.ok(result.channels.length <= 250, `unbounded live channel response returned ${result.channels.length} entries`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('projected live list returns only a bounded variant preview', () => {
|
||||
const db = createProjectionDb();
|
||||
const logicalId = 'logical:heavy';
|
||||
db.prepare(`
|
||||
INSERT INTO logical_channels
|
||||
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||
VALUES (?, 'heavy channel', 'Heavy Channel', '1:0', 2500, 1, 1)
|
||||
`).run(logicalId);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, 'heavy channel', ?, 'HD', 2, ?, 'en', 1, 'laya', ?)
|
||||
`);
|
||||
const insertAll = db.transaction(() => {
|
||||
for (let index = 0; index < 2500; index++) {
|
||||
insert.run(`1:${index}`, String(index), `Heavy Channel variant ${index}`,
|
||||
`https://streams.test/${index}.m3u8`, logicalId, `group-${index}`, index, `language-${index}`);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
try {
|
||||
const result = liveProjection.getChannelPage({ includeHidden: false, userId: 7 }, db);
|
||||
assert.equal(result.channels.length, 1);
|
||||
assert.equal(result.channels[0].variantCount, 2500);
|
||||
assert.ok(result.channels[0].variants.length <= 3);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
20
test/t37SettingsActions.test.js
Normal file
20
test/t37SettingsActions.test.js
Normal file
@ -0,0 +1,20 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const indexHtml = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
||||
const settingsSource = fs.readFileSync(path.join(root, 'public', 'js', 'pages', 'Settings.js'), 'utf8');
|
||||
|
||||
test('Settings exposes separate live check-new and full rebuild actions', () => {
|
||||
assert.ok(/id="live-check-new-button"[^>]*>\s*Check for new streams\s*</.test(indexHtml), 'Settings should expose a Check for new streams button');
|
||||
assert.ok(/id="live-rebuild-catalog-button"[^>]*>\s*Rebuild entire catalog\s*</.test(indexHtml), 'Settings should expose a Rebuild entire catalog button');
|
||||
});
|
||||
|
||||
test('Settings wires check-new and full rebuild buttons to distinct API actions', () => {
|
||||
assert.ok(/getElementById\('live-check-new-button'\).*addEventListener/s.test(settingsSource), 'Settings should bind the check-new button');
|
||||
assert.ok(/getElementById\('live-rebuild-catalog-button'\).*addEventListener/s.test(settingsSource), 'Settings should bind the full rebuild button');
|
||||
assert.ok(/projection\/check-new|checkNewStreams|checkNew/.test(settingsSource), 'Settings should call a check-new API action');
|
||||
assert.ok(/projection\/rebuild|rebuildProjection|runLiveAnalysis/.test(settingsSource), 'Settings should keep a full rebuild API action');
|
||||
});
|
||||
Reference in New Issue
Block a user