diff --git a/public/css/main.css b/public/css/main.css index 46070f9..e8b4da3 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -884,10 +884,21 @@ h4 { gap: var(--space-md); } +.live-analysis-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-sm); +} + @media (max-width: 600px) { .live-analysis-heading { flex-direction: column; } + + .live-analysis-actions { + justify-content: flex-start; + } } .laya-model-panel [hidden] { diff --git a/public/index.html b/public/index.html index 493807b..2b38544 100644 --- a/public/index.html +++ b/public/index.html @@ -489,9 +489,12 @@ Channel analysis
Group matching streams into channels and update detected languages.
- +
+ + +
-

Use this to analyze channels that were added before automatic analysis was enabled.

+

Check newly added streams, or rebuild the entire catalog if channel matching needs to be refreshed.

Checking status…
- ${channel.variants.length > 1 ? `
${variants}
` : ''} + ${variantCount > 1 && expanded ? `
${variants}${hasMoreVariants ? `` : ''}
` : ''} `; } + async loadChannelVariants(logicalId, append = false) { + const channel = this.projectedLogicalChannels?.find(item => item.fingerprint === logicalId); + if (!channel || !API.live.getChannelVariants) return; + this.variantPages ||= new Map(); + const current = this.variantPages.get(logicalId); + if (!append && current?.loaded) return; + const offset = append ? (current?.page?.nextOffset || 0) : 0; + const response = await API.live.getChannelVariants(logicalId, { + includeHidden: Boolean(this.showHiddenCheckbox?.checked), + limit: 100, + offset + }); + const rawVariants = append ? [...(current?.rawVariants || []), ...(response.variants || [])] : (response.variants || []); + const mapped = LiveChannelProjection.mapResponse({ + channels: [{ ...channel, variants: rawVariants }] + }, this.sources).logicalChannels?.[0]; + if (!mapped) return; + channel.variants = mapped.variants; + channel.visibleVariants = mapped.visibleVariants; + channel.preferredVariant = mapped.preferredVariant; + this.variantPages.set(logicalId, { loaded: true, rawVariants, page: response.page || null }); + } + /** * Render channels for a specific group (called when expanding a collapsed group) */ @@ -1060,9 +1140,11 @@ class ChannelList { try { const response = await API.live.getChannels({ sourceId, - includeHidden: true, - q: '', - languages: [] + includeHidden: Boolean(this.showHiddenCheckbox?.checked), + q: this.searchInput?.value || '', + languages: [...this.selectedLanguages], + limit: this.projectionLimit, + offset: 0 }); if (!LiveChannelProjection.canUse(response)) { this.projectedLogicalChannels = null; @@ -1072,6 +1154,9 @@ class ChannelList { const mapped = LiveChannelProjection.mapResponse(response, this.sources); this.channels = mapped.channels; this.projectedLogicalChannels = mapped.logicalChannels; + this.projectionPage = response.page || null; + this.variantPages ||= new Map(); + this.variantPages.clear(); this.availableLanguages = mapped.availableLanguages; this.reconcileLanguageSelection(); this.renderLanguageFilter(); @@ -1081,6 +1166,7 @@ class ChannelList { return true; } catch (err) { this.projectedLogicalChannels = null; + this.projectionPage = null; console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message); this.projectionNotice = ''; return false; @@ -1117,7 +1203,8 @@ class ChannelList { sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages])); } catch {} this.renderLanguageFilter(); - if (this.projectedLogicalChannels || this.channels.length) this.render(); + if (this.projectionPage) this.loadChannels(); + else if (this.projectedLogicalChannels || this.channels.length) this.render(); else this.loadChannels(); } diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js index 6fd5539..acbf73b 100644 --- a/public/js/pages/Settings.js +++ b/public/js/pages/Settings.js @@ -107,7 +107,12 @@ class SettingsPage { } initLiveAnalysis() { - document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis()); + document.getElementById('live-check-new-button')?.addEventListener('click', () => void this.runLiveAnalysis('check-new')); + document.getElementById('live-rebuild-catalog-button')?.addEventListener('click', () => { + if (confirm('Rebuild the entire channel catalog? This can take a while.')) void this.runLiveAnalysis('rebuild'); + }); + // Compatibility with pages cached before the actions were split. + document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis('rebuild')); document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => { const button = document.getElementById('live-analysis-details-toggle'); const details = document.getElementById('live-analysis-error-details'); @@ -131,7 +136,11 @@ class SettingsPage { } renderLiveAnalysisStatus(status = {}) { - const button = document.getElementById('live-analysis-button'); + const buttons = [ + document.getElementById('live-check-new-button'), + document.getElementById('live-rebuild-catalog-button'), + document.getElementById('live-analysis-button') + ].filter(Boolean); const statusElement = document.getElementById('live-analysis-status'); const progress = document.getElementById('live-analysis-progress'); const track = document.getElementById('live-analysis-progress-track'); @@ -140,11 +149,10 @@ class SettingsPage { const fallback = document.getElementById('live-analysis-fallback'); const detailsButton = document.getElementById('live-analysis-details-toggle'); const details = document.getElementById('live-analysis-error-details'); - if (!button || !statusElement) return; + if (!buttons.length || !statusElement) return; const presentation = LiveAnalysisPresentation.present(status); - button.textContent = presentation.buttonLabel; - button.disabled = presentation.busy; + buttons.forEach(button => { button.disabled = presentation.busy; }); statusElement.textContent = presentation.statusLabel; progress.hidden = !presentation.busy; track.classList.toggle('indeterminate', presentation.progressIndeterminate); @@ -169,14 +177,19 @@ class SettingsPage { details.textContent = presentation.errorDetail; } - async runLiveAnalysis() { - const button = document.getElementById('live-analysis-button'); - if (button?.disabled) return; + async runLiveAnalysis(action = 'rebuild') { + const buttons = ['live-check-new-button', 'live-rebuild-catalog-button', 'live-analysis-button'] + .map(id => document.getElementById(id)).filter(Boolean); + if (buttons.some(button => button.disabled)) return; this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 }; this.renderLiveAnalysisStatus(this.liveAnalysisStatus); try { - const response = await API.live.rebuildProjection(); - this.liveAnalysisStatus = response.projection || response; + const response = action === 'check-new' + ? await API.live.checkNewStreams() + : await API.live.rebuildProjection(); + this.liveAnalysisStatus = response.projection || (response.started + ? { status: 'queued', processed: 0 } + : response); this.renderLiveAnalysisStatus(this.liveAnalysisStatus); this.startLiveAnalysisPolling(); } catch (err) { diff --git a/server/routes/live.js b/server/routes/live.js index e0400cb..48dec3f 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -36,6 +36,8 @@ router.get('/channels', requireAuth, async (req, res) => { const includeHidden = req.query.includeHidden === 'true'; const q = String(req.query.q || '').slice(0, 200); const languages = String(req.query.languages || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean); + const page = Number.parseInt(req.query.page, 10) || 1; + const limit = Number.parseInt(req.query.limit, 10) || 100; if (languages.some(code => !/^(?:[a-z]{2,3}|und)$/.test(code))) { return res.status(400).json({ error: 'languages must be comma-separated language codes' }); } @@ -46,10 +48,10 @@ router.get('/channels', requireAuth, async (req, res) => { state = await liveProjection.startRebuild(null, { mode: 'algo' }).promise; } const epgSources = await sources.getByType('epg'); - const { channels, availableLanguages } = liveProjection.getChannelPage({ - sourceId, includeHidden, userId: req.user.id, epgSources, q, languages + const result = liveProjection.getChannelPage({ + sourceId, includeHidden, userId: req.user.id, epgSources, q, languages, page, limit }); - return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels }); + return res.json({ projection: serializeState(state), filters: { availableLanguages: result.availableLanguages }, channels: result.channels, page: result.page }); } catch (error) { console.error('[LiveProjection] Failed to serve projected channels:', error.message); const state = liveProjection.projectionState(); @@ -61,6 +63,24 @@ router.get('/channels', requireAuth, async (req, res) => { } }); +router.get('/channels/:logicalChannelId/variants', requireAuth, (req, res) => { + try { + const page = Number.parseInt(req.query.page, 10) || 1; + const limit = Number.parseInt(req.query.limit, 10) || 100; + const includeHidden = req.query.includeHidden === 'true'; + return res.json(liveProjection.getChannelVariants({ + logicalChannelId: req.params.logicalChannelId, + includeHidden, + userId: req.user.id, + page, + limit + })); + } catch (error) { + console.error('[LiveProjection] Failed to serve channel variants:', error.message); + return res.status(500).json({ error: 'Unable to read channel variants' }); + } +}); + router.get('/projection/status', requireAuth, (req, res) => { try { return res.json(serializeState(liveProjection.projectionState())); @@ -85,6 +105,17 @@ router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => { } }); +router.post('/projection/check-new', requireAuth, requireAdmin, (req, res) => { + try { + const check = liveProjection.checkNewStreams(); + check.catch(error => console.error('[LiveProjection] Incremental check failed:', error.message)); + return res.status(202).json({ started: true }); + } catch (error) { + console.error('[LiveProjection] Unable to start incremental check:', error.message); + return res.status(500).json({ error: 'Unable to start incremental Live analysis' }); + } +}); + router.post('/projection/standards/refresh', requireAuth, requireAdmin, async (req, res) => { try { const result = await iptvOrgCatalog.refresh(getDb()); diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index 7e85b01..f3bffa6 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -6,6 +6,8 @@ const iptvOrgCatalog = require('./iptvOrgCatalogService'); const epgMatching = require('./epgMatchingService'); const MAX_SYNCHRONOUS_ENRICHMENTS = 250; +const DEFAULT_PAGE_LIMIT = 100; +const MAX_PAGE_LIMIT = 200; const QUALITY_TOKENS = new Set([ '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', @@ -395,7 +397,45 @@ function parseData(value) { try { return value ? JSON.parse(value) : {}; } catch { return {}; } } -function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null, dependencies = {}) { +function pageBounds(page = 1, limit = DEFAULT_PAGE_LIMIT) { + const normalizedPage = Math.max(1, Number.parseInt(page, 10) || 1); + const normalizedLimit = Math.min(MAX_PAGE_LIMIT, Math.max(1, Number.parseInt(limit, 10) || DEFAULT_PAGE_LIMIT)); + return { page: normalizedPage, limit: normalizedLimit, offset: (normalizedPage - 1) * normalizedLimit }; +} + +function pageMetadata(total, bounds) { + const nextOffset = bounds.offset + bounds.limit; + const hasMore = nextOffset < total; + return { + page: bounds.page, + limit: bounds.limit, + offset: bounds.offset, + total, + hasMore, + nextOffset: hasMore ? nextOffset : null + }; +} + +function listChannel(channel) { + const preferredVariant = channel.preferredVariant || channel.variants[0] || null; + const variants = []; + const addPreview = variant => { + if (variant && variants.length < 3 && !variants.some(candidate => + candidate.id === variant.id && candidate.sourceId === variant.sourceId + )) variants.push(variant); + }; + addPreview(preferredVariant); + channel.variants.filter(variant => variant.isFavorite).forEach(addPreview); + channel.variants.filter(variant => !variant.isHidden).forEach(addPreview); + return { + ...channel, + preferredVariant, + variantCount: channel.variants.length, + variants + }; +} + +function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) { database ||= defaultDatabase(); const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog; const epgMatcher = dependencies.epgMatching || epgMatching; @@ -431,11 +471,13 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null, const filtered = serialized.filter(channel => (!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q) ); + const bounds = pageBounds(page, limit); + const paged = filtered.slice(bounds.offset, bounds.offset + bounds.limit); // Catalog loading must stay bounded: both enrichers perform synchronous SQLite work, // and EPG matching is especially expensive against large XMLTV catalogs. Enrich only // a genuinely targeted, bounded search; base projection data remains usable otherwise. const shouldEnrich = Boolean(String(q).trim()) && filtered.length <= MAX_SYNCHRONOUS_ENRICHMENTS; - const channels = !shouldEnrich ? filtered : filtered.map(channel => { + const enriched = !shouldEnrich ? paged : paged.map(channel => { const match = standardCatalog.findMatch(database, channel); if (match) { channel.standardName = match.name; @@ -445,7 +487,37 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null, if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code }; return channel; }); - return { channels, availableLanguages }; + return { + channels: enriched.map(listChannel), + availableLanguages, + page: pageMetadata(filtered.length, bounds) + }; +} + +function getChannelVariants({ logicalChannelId, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) { + database ||= defaultDatabase(); + const rows = database.prepare(` + SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite, + l.display_name, l.preferred_variant_id, c.name AS category_name, + COALESCE(c.is_hidden, 0) AS category_hidden + FROM playlist_items p + JOIN logical_channels l ON l.id = p.logical_channel_id + LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id + LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id + AND f.item_id = p.item_id AND f.item_type = 'channel' + WHERE p.type = 'live' AND p.logical_channel_id = ? + ORDER BY p.variant_rank, p.id + `).all(userId ?? -1, logicalChannelId); + const variants = serializeChannels(rows, includeHidden).flatMap(channel => channel.variants); + const bounds = pageBounds(page, limit); + return { + variants: variants.slice(bounds.offset, bounds.offset + bounds.limit), + page: pageMetadata(variants.length, bounds) + }; +} + +function checkNewStreams() { + return require('./syncService').syncAll(); } function getChannels(options, database = null) { @@ -527,7 +599,9 @@ module.exports = { collectIdentityHints, collectOptionalIdentityHints, displayNameForFingerprint, + checkNewStreams, getChannelPage, + getChannelVariants, getChannels, identityCandidatePairs, normalizeChannelName, diff --git a/test/liveAnalysisUserFlowDom.test.js b/test/liveAnalysisUserFlowDom.test.js index 50b9af5..0be1997 100644 --- a/test/liveAnalysisUserFlowDom.test.js +++ b/test/liveAnalysisUserFlowDom.test.js @@ -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); diff --git a/test/liveProjectionRoutes.test.js b/test/liveProjectionRoutes.test.js index 6eda485..161354c 100644 --- a/test/liveProjectionRoutes.test.js +++ b/test/liveProjectionRoutes.test.js @@ -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); diff --git a/test/liveProjectionService.test.js b/test/liveProjectionService.test.js index 2935c25..3796157 100644 --- a/test/liveProjectionService.test.js +++ b/test/liveProjectionService.test.js @@ -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); }); diff --git a/test/t37LiveListPerformanceRegression.test.js b/test/t37LiveListPerformanceRegression.test.js new file mode 100644 index 0000000..2801219 --- /dev/null +++ b/test/t37LiveListPerformanceRegression.test.js @@ -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(); + } +}); diff --git a/test/t37SettingsActions.test.js b/test/t37SettingsActions.test.js new file mode 100644 index 0000000..9179999 --- /dev/null +++ b/test/t37SettingsActions.test.js @@ -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*]*>\s*Rebuild entire catalog\s* { + 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'); +});