From d018ac078caf64a9d8e5c1580d5ff1b7f21b532b Mon Sep 17 00:00:00 2001 From: blomios Date: Tue, 29 Sep 2026 23:31:05 +0200 Subject: [PATCH 01/36] feat: add Plex-style adaptive transcoding with bitrate limits and hardware fallback --- public/index.html | 55 +++++++++--- public/js/components/VideoPlayer.js | 78 +++++++++++++---- public/js/pages/Settings.js | 14 +++ server/db.js | 3 + server/routes/probe.js | 33 +++++++- server/routes/transcode.js | 14 ++- server/services/transcodeSession.js | 127 +++++++++++++++++++++------- 7 files changed, 258 insertions(+), 66 deletions(-) diff --git a/public/index.html b/public/index.html index dd74211..5f89d4a 100644 --- a/public/index.html +++ b/public/index.html @@ -539,7 +539,7 @@

Encoder Settings

- Hardware Encoder + Transcoding Device Select which GPU encoder to use for transcoding. Auto will use the best available.
@@ -554,7 +554,7 @@
- Max Resolution + Maximum Transcoded Resolution Limit transcoded output resolution. Lower = faster, more compatible.
@@ -567,7 +567,7 @@
- Quality Preset + Encoding Quality Higher quality uses more bandwidth and CPU/GPU.
@@ -617,7 +617,7 @@
+
+
+ Maximum Streaming Bandwidth + The bandwidth limit is evaluated when playback starts. Quality does not change automatically during playback. +
+ +
+ + +
+

Stream Processing

- Force Audio Transcode + Always Convert Audio Transcode audio to AAC for browser compatibility. Enable if you get video but no audio (fixes Dolby/AC3/EAC3).
@@ -658,7 +676,7 @@
- Force Video Transcode + Always Convert Video Force full video transcoding (burn-in subtitles, incompatible video). Uses HW encoder. @@ -670,14 +688,14 @@
- Force Remux + Always Remux Streams Remux streams to MP4 container. Enable for raw .ts streams from IPTV middleware like m3u-editor or dispatcharr.
- Stream Output Format + Stream Container Container format for Xtream streams. Try TS if you experience buffering issues.
@@ -686,6 +704,15 @@
+
+ +
+ Use CPU if hardware transcoding fails +
+
@@ -1125,4 +1152,4 @@ - \ No newline at end of file + diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 5e81fce..c62d9f0 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -53,6 +53,9 @@ class VideoPlayer { forceTranscode: false, forceRemux: false, autoTranscode: true, + maxDirectBitrateKbps: 0, + targetTranscodeBitrateKbps: 6000, + allowHardwareFallback: true, streamFormat: 'm3u8', epgRefreshInterval: '24' }; @@ -813,6 +816,7 @@ class VideoPlayer { * Start a HLS transcode session */ async startTranscodeSession(url, options = {}) { + this.currentTranscodeSession = null; try { console.log('[Player] Starting HLS transcode session...', options); const res = await fetch('/api/transcode/session', { @@ -823,6 +827,7 @@ class VideoPlayer { if (!res.ok) throw new Error('Failed to start session'); const session = await res.json(); this.currentSessionId = session.sessionId; + this.currentTranscodeSession = session; return session.playlistUrl; } catch (err) { console.error('[Player] Session start failed:', err); @@ -871,9 +876,14 @@ class VideoPlayer { // Determine if HLS or direct stream this.currentUrl = streamUrl; - // CHECK: Auto Transcode (Smart) - probe first, then decide - if (this.settings.autoTranscode) { + // Forced playback modes take priority over automatic selection below. + const hasForcedPlaybackMode = this.settings.forceVideoTranscode || + this.settings.forceTranscode || this.settings.forceRemux; + + // Automatic playback: probe once, then choose transcode, remux, or direct play. + if (this.settings.autoTranscode && !hasForcedPlaybackMode) { console.log('[Player] Auto Transcode enabled. Probing stream...'); + this.updateTranscodeStatus('direct', 'Analyzing…'); try { const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const info = await probeRes.json(); @@ -905,14 +915,23 @@ class VideoPlayer { } } - if (info.needsTranscode || this.settings.upscaleEnabled) { + const maxDirectBitrateKbps = Number(this.settings.maxDirectBitrateKbps) || 0; + const overallBitrateKbps = Number(info.overallBitrateKbps) || 0; + const exceedsBandwidthLimit = maxDirectBitrateKbps > 0 && + overallBitrateKbps > maxDirectBitrateKbps; + const requiresTranscode = info.needsTranscode || this.settings.upscaleEnabled || + (!info.needsRemux && exceedsBandwidthLimit); + + if (requiresTranscode) { // Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session console.log(`[Player] Auto: Using HLS transcode session (${this.settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`); // Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only) // BUT: If upscaling is enabled, we MUST encode. - const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode'; - const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'); + const videoMode = (info.video && info.video.includes('h264') && + !this.settings.upscaleEnabled && !exceedsBandwidthLimit) ? 'copy' : 'encode'; + const statusText = videoMode === 'copy' ? 'Audio Transcode' : + (this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode'); const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding'; this.updateTranscodeStatus(statusMode, statusText); @@ -920,8 +939,18 @@ class VideoPlayer { videoMode, videoCodec: info.video, audioCodec: info.audio, - audioChannels: info.audioChannels + audioChannels: info.audioChannels, + targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000, + allowHardwareFallback: this.settings.allowHardwareFallback !== false }); + if (this.currentTranscodeSession?.hardwareFallbackOccurred) { + this.updateTranscodeStatus('transcoding', 'CPU Fallback'); + } + if (exceedsBandwidthLimit) { + this.updateQualityBadge(this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000); + } this.currentUrl = playlistUrl; // Update currentUrl for HLS reload this.playHls(playlistUrl); @@ -934,7 +963,7 @@ class VideoPlayer { } else if (info.needsRemux) { // Raw .ts container - use remux console.log('[Player] Auto: Using remux (.ts container)'); - this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); + this.updateTranscodeStatus('remuxing', 'Remux'); const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`; this.currentUrl = remuxUrl; this.video.src = remuxUrl; @@ -949,6 +978,7 @@ class VideoPlayer { } // Compatible - fall through to normal HLS.js path console.log('[Player] Auto: Using HLS.js (compatible)'); + this.updateTranscodeStatus('direct', 'Direct Play'); } catch (err) { console.warn('[Player] Probe failed, using normal playback:', err.message); // Continue with normal playback on probe failure @@ -957,11 +987,19 @@ class VideoPlayer { // CHECK: Force Video Transcode (Full) or Upscaling if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) { - const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'; + const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode'; const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding'; console.log(`[Player] ${statusText} enabled. Starting session (encode)...`); this.updateTranscodeStatus(statusMode, statusText); - const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' }); + const playlistUrl = await this.startTranscodeSession(streamUrl, { + videoMode: 'encode', + targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000, + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }); + if (this.currentTranscodeSession?.hardwareFallbackOccurred) { + this.updateTranscodeStatus('transcoding', 'CPU Fallback'); + } this.currentUrl = playlistUrl; // Load HLS @@ -1002,7 +1040,7 @@ class VideoPlayer { // CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting if (this.settings.forceTranscode) { console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...'); - this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)'); + this.updateTranscodeStatus('transcoding', 'Audio Transcode'); // Probe to get video codec for HEVC tag handling let videoCodec = 'unknown'; @@ -1012,7 +1050,11 @@ class VideoPlayer { videoCodec = info.video; } catch (e) { console.warn('Probe failed for force audio, assuming h264'); } - const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec }); + const playlistUrl = await this.startTranscodeSession(streamUrl, { + videoMode: 'copy', + videoCodec, + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }); this.currentUrl = playlistUrl; console.log('[Player] Playing transcoded HLS stream:', playlistUrl); @@ -1053,7 +1095,7 @@ class VideoPlayer { if (this.settings.forceRemux && (isRawTs || isExtensionless)) { console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...'); console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)'); - this.updateTranscodeStatus('remuxing', 'Remux (Force)'); + this.updateTranscodeStatus('remuxing', 'Remux'); const remuxUrl = this.getRemuxUrl(streamUrl); this.video.src = remuxUrl; this.video.play().catch(e => { @@ -1082,7 +1124,7 @@ class VideoPlayer { // Priority 1: Use HLS.js for HLS streams on browsers that support it if (looksLikeHls && Hls.isSupported()) { - this.updateTranscodeStatus('direct', 'Direct HLS'); + this.updateTranscodeStatus('direct', 'Direct Play'); // Use playHls helper logic here (or extract it) // For now, let's just use existing logic but wrapped/modularized if possible? @@ -1146,7 +1188,7 @@ class VideoPlayer { } else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' || this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') { // Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work) - this.updateTranscodeStatus('direct', 'Direct Native'); + this.updateTranscodeStatus('direct', 'Direct Play'); this.video.src = finalUrl; this.video.play().catch(e => { if (e.name === 'AbortError') return; // Ignore interruption by new load @@ -1247,11 +1289,15 @@ class VideoPlayer { /** * Update quality badge display */ - updateQualityBadge() { + updateQualityBadge(targetBitrateKbps = null) { const badge = document.getElementById('player-quality-badge'); if (!badge) return; - if (this.currentStreamInfo?.height > 0) { + if (targetBitrateKbps) { + const targetMbps = targetBitrateKbps / 1000; + badge.textContent = `${Number.isInteger(targetMbps) ? targetMbps : targetMbps.toFixed(1)} Mbps`; + badge.classList.remove('hidden'); + } else if (this.currentStreamInfo?.height > 0) { badge.textContent = this.getQualityLabel(this.currentStreamInfo.height); badge.classList.remove('hidden'); } else { diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js index a47b5d7..a1208b4 100644 --- a/public/js/pages/Settings.js +++ b/public/js/pages/Settings.js @@ -107,6 +107,8 @@ class SettingsPage { const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc'); const forceRemuxToggle = document.getElementById('setting-force-remux-tc'); const streamFormatSelect = document.getElementById('setting-stream-format-tc'); + const maxDirectBitrateSelect = document.getElementById('setting-max-direct-bitrate'); + const allowHardwareFallbackToggle = document.getElementById('setting-allow-hardware-fallback'); // User-Agent (Transcoding tab versions) const userAgentSelect = document.getElementById('setting-user-agent-tc'); @@ -131,6 +133,8 @@ class SettingsPage { if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8'; + if (maxDirectBitrateSelect) maxDirectBitrateSelect.value = String(s.maxDirectBitrateKbps || 0); + if (allowHardwareFallbackToggle) allowHardwareFallbackToggle.checked = s.allowHardwareFallback !== false; if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome'; if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || ''; if (customUaContainer) { @@ -232,6 +236,16 @@ class SettingsPage { this.app.player.saveSettings(); }); + maxDirectBitrateSelect?.addEventListener('change', () => { + this.app.player.settings.maxDirectBitrateKbps = parseInt(maxDirectBitrateSelect.value, 10) || 0; + this.app.player.saveSettings(); + }); + + allowHardwareFallbackToggle?.addEventListener('change', () => { + this.app.player.settings.allowHardwareFallback = allowHardwareFallbackToggle.checked; + this.app.player.saveSettings(); + }); + // User-Agent handlers const toggleCustomInput = () => { if (customUaContainer) { diff --git a/server/db.js b/server/db.js index 127d9b8..0291161 100644 --- a/server/db.js +++ b/server/db.js @@ -76,6 +76,9 @@ function getDefaultSettings() { hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software maxResolution: '1080p', // 4k | 1080p | 720p | 480p quality: 'medium', // high | medium | low + maxDirectBitrateKbps: 0, // 0 = unlimited + targetTranscodeBitrateKbps: 6000, + allowHardwareFallback: true, audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough // Probe cache settings probeCacheTTL: 300, // 5 minutes for URL probe cache diff --git a/server/routes/probe.js b/server/routes/probe.js index 751ef43..2baf2d5 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -87,6 +87,22 @@ function analyzeProbeResult(probeResult, url) { const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown'; const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown'; const container = format.format_name?.toLowerCase() || 'unknown'; + const toPositiveNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }; + const toKbps = (value) => { + const bitrate = toPositiveNumber(value); + return bitrate === null ? null : Math.round(bitrate / 1000); + }; + const durationSeconds = toPositiveNumber(format.duration) ?? toPositiveNumber(videoStream?.duration) ?? toPositiveNumber(audioStream?.duration); + const videoBitrateKbps = toKbps(videoStream?.bit_rate); + const audioBitrateKbps = toKbps(audioStream?.bit_rate); + const overallBitrateKbps = toKbps(format.bit_rate); + const probeConfidence = durationSeconds !== null && videoBitrateKbps !== null && + (!audioStream || audioBitrateKbps !== null) && overallBitrateKbps !== null + ? 'complete' + : 'partial'; // Check codec compatibility const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c)); @@ -131,6 +147,11 @@ function analyzeProbeResult(probeResult, url) { width: videoStream?.width || 0, height: videoStream?.height || 0, audioChannels: audioStream?.channels || 0, // For Smart Audio Copy + durationSeconds, + videoBitrateKbps, + audioBitrateKbps, + overallBitrateKbps, + probeConfidence, container: container, compatible: compatible, needsRemux: needsRemux, @@ -157,7 +178,12 @@ router.get('/', async (req, res) => { container: 'unknown', compatible: false, needsRemux: false, - needsTranscode: true + needsTranscode: true, + durationSeconds: null, + videoBitrateKbps: null, + audioBitrateKbps: null, + overallBitrateKbps: null, + probeConfidence: 'partial' }); } @@ -193,6 +219,11 @@ router.get('/', async (req, res) => { compatible: false, needsRemux: false, needsTranscode: true, + durationSeconds: null, + videoBitrateKbps: null, + audioBitrateKbps: null, + overallBitrateKbps: null, + probeConfidence: 'partial', error: err.message }); } diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 3f82ec9..b11ef57 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -29,7 +29,10 @@ transcodeSession.startCleanupInterval(); * Body: { url: string, seekOffset?: number } */ router.post('/session', async (req, res) => { - const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body; + const { + url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, + targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback + } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); @@ -47,6 +50,9 @@ router.post('/session', async (req, res) => { hwEncoder: settings.hwEncoder || 'software', maxResolution: settings.maxResolution || '1080p', quality: settings.quality || 'medium', + targetVideoBitrateKbps: targetVideoBitrateKbps ?? settings.targetTranscodeBitrateKbps ?? 6000, + targetAudioBitrateKbps: targetAudioBitrateKbps ?? 192, + allowHardwareFallback: allowHardwareFallback ?? settings.allowHardwareFallback ?? true, audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset // Upscaling options upscaleEnabled: settings.upscaleEnabled || false, @@ -71,7 +77,10 @@ router.post('/session', async (req, res) => { res.json({ sessionId: session.id, playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, - status: session.status + status: session.status, + requestedEncoder: session.requestedEncoder, + effectiveEncoder: session.effectiveEncoder, + hardwareFallbackOccurred: session.hardwareFallbackOccurred }); } catch (err) { @@ -270,4 +279,3 @@ router.get('/', async (req, res) => { }); module.exports = router; - diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js index 4d6c2f7..c473476 100644 --- a/server/services/transcodeSession.js +++ b/server/services/transcodeSession.js @@ -63,20 +63,28 @@ class TranscodeSession extends EventEmitter { this.segments = new Map(); // segment index -> { ready: boolean, path: string } this.status = 'pending'; // pending | starting | running | stopped | error this.error = null; + this.requestedEncoder = options.hwEncoder || 'software'; + this.effectiveEncoder = null; + this.hardwareFallbackOccurred = false; + this.playlistReady = false; + this.processGeneration = 0; this.startTime = Date.now(); this.lastAccess = Date.now(); this.options = { + ...options, ffmpegPath: options.ffmpegPath || 'ffmpeg', userAgent: options.userAgent || 'Mozilla/5.0', seekOffset: options.seekOffset || 0, hwEncoder: options.hwEncoder || 'software', maxResolution: options.maxResolution || '1080p', quality: options.quality || 'medium', + targetVideoBitrateKbps: this.normalizeBitrate(options.targetVideoBitrateKbps, 6000), + targetAudioBitrateKbps: this.normalizeBitrate(options.targetAudioBitrateKbps, 192), + allowHardwareFallback: options.allowHardwareFallback !== false, // Upscaling options upscaleEnabled: options.upscaleEnabled || false, upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software' - upscaleTarget: options.upscaleTarget || '1080p', - ...options + upscaleTarget: options.upscaleTarget || '1080p' }; } @@ -100,27 +108,62 @@ class TranscodeSession extends EventEmitter { throw err; } - // Build FFmpeg arguments for HLS output - const args = this.buildFFmpegArgs(); + try { + const encoder = this.resolveEncoder(); + this.requestedEncoder = this.options.hwEncoder || 'software'; + this.startProcess(encoder); + // Save session metadata + await this.persist(); + + } catch (err) { + this.status = 'error'; + this.error = err.message; + throw err; + } + } + + normalizeBitrate(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; + } + + resolveEncoder() { + let encoder = this.options.hwEncoder || 'software'; + if (encoder === 'auto') { + const hwCaps = hwDetect.getCapabilities(); + encoder = hwCaps?.recommended || 'software'; + console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); + } + return encoder; + } + + startProcess(encoder) { + const args = this.buildFFmpegArgs(encoder); + const generation = ++this.processGeneration; + this.effectiveEncoder = encoder; + this.status = 'starting'; + this.error = null; + console.log(`[TranscodeSession ${this.id}] Starting with requested encoder=${this.requestedEncoder}, effective encoder=${encoder}`); console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`); try { - this.process = spawn(this.options.ffmpegPath, args, { + const process = spawn(this.options.ffmpegPath, args, { cwd: this.dir, windowsHide: true }); + this.process = process; this.status = 'running'; // Handle stdout (should be empty for file output) - this.process.stdout.on('data', (data) => { + process.stdout.on('data', (data) => { console.log(`[TranscodeSession ${this.id}] stdout: ${data}`); }); // Handle stderr (FFmpeg progress/errors) let stderrBuffer = ''; - this.process.stderr.on('data', (data) => { + process.stderr.on('data', (data) => { stderrBuffer += data.toString(); // Log periodically to avoid spam const lines = stderrBuffer.split('\n'); @@ -135,51 +178,62 @@ class TranscodeSession extends EventEmitter { }); // Handle process exit - this.process.on('exit', (code) => { + process.on('exit', (code) => { + if (generation !== this.processGeneration) return; if (code === 0 || code === null) { console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`); this.status = 'stopped'; } else if (code !== 255) { // 255 is often from SIGKILL - console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`); + const error = `FFmpeg exited with code ${code}`; + if (this.tryHardwareFallback(error, encoder)) return; + console.error(`[TranscodeSession ${this.id}] ${error}`); this.status = 'error'; - this.error = `FFmpeg exited with code ${code}`; + this.error = error; } this.process = null; this.emit('exit', code); }); // Handle spawn errors - this.process.on('error', (err) => { + process.on('error', (err) => { + if (generation !== this.processGeneration) return; + if (this.tryHardwareFallback(err.message, encoder)) return; console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err); this.status = 'error'; this.error = err.message; - this.emit('error', err); + if (this.listenerCount('error') > 0) { + this.emit('error', err); + } }); - // Save session metadata - await this.persist(); - } catch (err) { - this.status = 'error'; - this.error = err.message; + if (this.tryHardwareFallback(err.message, encoder)) return; throw err; } } + tryHardwareFallback(initialError, encoder) { + const canFallback = this.options.videoMode !== 'copy' && encoder !== 'software' && + this.options.allowHardwareFallback && !this.hardwareFallbackOccurred && !this.playlistReady; + if (!canFallback) return false; + + this.hardwareFallbackOccurred = true; + console.error(`[TranscodeSession ${this.id}] Hardware encoder ${encoder} failed during startup: ${initialError}`); + console.log(`[TranscodeSession ${this.id}] Retrying once with software encoder`); + this.startProcess('software'); + this.persist().catch(err => console.error(`[TranscodeSession ${this.id}] Failed to persist fallback state:`, err.message)); + return true; + } + /** * Build FFmpeg arguments for HLS output with optional GPU encoding */ - buildFFmpegArgs() { + buildFFmpegArgs(resolvedEncoder = null) { const segmentPattern = path.join(this.dir, 'seg%04d.m4s'); const videoMode = this.options.videoMode || 'encode'; // Resolve 'auto' encoder to detected hardware, fallback to software - let encoder = this.options.hwEncoder || 'software'; - if (encoder === 'auto') { - const hwCaps = hwDetect.getCapabilities(); - encoder = hwCaps?.recommended || 'software'; - console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); - } + const encoder = resolvedEncoder || this.resolveEncoder(); const args = [ '-hide_banner', @@ -264,7 +318,7 @@ class TranscodeSession extends EventEmitter { args.push( '-c:a', 'aac', '-ar', '48000', - '-b:a', '192k', + '-b:a', `${this.options.targetAudioBitrateKbps}k`, '-af', `${panFilter},aresample=async=1` ); } @@ -336,6 +390,7 @@ class TranscodeSession extends EventEmitter { 'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 } }; const qp = qualityPresets[quality] || qualityPresets.medium; + const bitrate = this.options.targetVideoBitrateKbps; switch (encoder) { case 'nvenc': @@ -356,6 +411,7 @@ class TranscodeSession extends EventEmitter { this.addSoftwareEncoderArgs(args, resolution, qp.software); break; } + args.push('-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`); } /** @@ -432,8 +488,8 @@ class TranscodeSession extends EventEmitter { args.push( '-c:v', 'h264_nvenc', '-preset', 'p4', // Balanced preset (p1=fastest, p7=best) - '-rc', 'constqp', // Constant QP mode - '-qp', String(qp), + '-rc', 'vbr', // Bitrate-constrained variable bitrate + '-cq', String(qp), // Quality target within bitrate constraint '-bf', '3' // B-frames for better compression ); } @@ -448,10 +504,7 @@ class TranscodeSession extends EventEmitter { args.push( '-c:v', 'h264_amf', '-quality', 'quality', // Quality preset - '-rc', 'cqp', // Constant QP - '-qp_i', String(qp), - '-qp_p', String(qp + 2), - '-qp_b', String(qp + 4), + '-rc', 'vbr_peak', // Bitrate-constrained variable bitrate '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility ); } @@ -556,6 +609,7 @@ class TranscodeSession extends EventEmitter { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { if (await this.isPlaylistReady()) { + this.playlistReady = true; return true; } await new Promise(resolve => setTimeout(resolve, 200)); @@ -600,6 +654,9 @@ class TranscodeSession extends EventEmitter { startTime: this.startTime, lastAccess: this.lastAccess, options: this.options, + requestedEncoder: this.requestedEncoder, + effectiveEncoder: this.effectiveEncoder, + hardwareFallbackOccurred: this.hardwareFallbackOccurred, seekOffset: this.options.seekOffset }; const metaPath = path.join(this.dir, 'session.json'); @@ -621,6 +678,9 @@ class TranscodeSession extends EventEmitter { session.startTime = metadata.startTime; session.lastAccess = metadata.lastAccess; session.status = 'stopped'; // Not running after restart + session.requestedEncoder = metadata.requestedEncoder || session.requestedEncoder; + session.effectiveEncoder = metadata.effectiveEncoder || null; + session.hardwareFallbackOccurred = metadata.hardwareFallbackOccurred || false; return session; } catch (err) { console.error(`Failed to restore session from ${sessionDir}:`, err.message); @@ -753,7 +813,10 @@ function getAllSessions() { status: s.status, startTime: s.startTime, lastAccess: s.lastAccess, - idleMs: Date.now() - s.lastAccess + idleMs: Date.now() - s.lastAccess, + requestedEncoder: s.requestedEncoder, + effectiveEncoder: s.effectiveEncoder, + hardwareFallbackOccurred: s.hardwareFallbackOccurred })); } From b6569eb81e94c81568eaab4979e9fbce53471366 Mon Sep 17 00:00:00 2001 From: blomios Date: Tue, 29 Sep 2026 23:34:11 +0200 Subject: [PATCH 02/36] chore: add Docker build/push script targeting Gitea registry and use published image in README --- README.md | 4 +++- scripts/docker-build-push.sh | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100755 scripts/docker-build-push.sh diff --git a/README.md b/README.md index 19dfcdf..5ea5686 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ You can run nodecast-tv easily using Docker. ```yaml services: nodecast-tv: - build: https://github.com/technomancer702/nodecast-tv.git#main + image: gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest container_name: nodecast-tv ports: - "3000:3000" # Host:Container @@ -90,6 +90,8 @@ You can run nodecast-tv easily using Docker. The application will be available at `http://localhost:3000`. +To publish the image, log in if needed with `docker login gitea.anthonybouteiller.ovh`, then run `./scripts/docker-build-push.sh`. Use `--no-push` to build it locally without publishing. + ### Hardware Acceleration Setup diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh new file mode 100755 index 0000000..1df4070 --- /dev/null +++ b/scripts/docker-build-push.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REGISTRY=${REGISTRY:-gitea.anthonybouteiller.ovh} +OWNER=${OWNER:-blomios} +IMAGE_NAME=${IMAGE_NAME:-nodecast-tv} +TAG=${TAG:-latest} +IMAGE="${REGISTRY}/${OWNER}/${IMAGE_NAME}:${TAG}" + +push_image=true +if [[ "${1:-}" == "--no-push" ]]; then + push_image=false +elif [[ $# -gt 0 ]]; then + echo "Usage: $0 [--no-push]" >&2 + exit 2 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker is required but was not found in PATH." >&2 + exit 1 +fi + +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +echo "Target image: ${IMAGE}" +docker build --file "${repo_dir}/Dockerfile" --tag "${IMAGE}" "${repo_dir}" + +if [[ "${DRY_RUN:-false}" == "true" || "${push_image}" == "false" ]]; then + echo "Push skipped." + exit 0 +fi + +docker push "${IMAGE}" From 00a299b261391129fc03e03a5a0d585cb01a675d Mon Sep 17 00:00:00 2001 From: blomios Date: Wed, 30 Sep 2026 08:46:03 +0200 Subject: [PATCH 03/36] fix: switch docker-compose to Gitea registry image and drop GitHub build --- docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6de58db..5487586 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: nodecast-tv: - image: ghcr.io/technomancer702/nodecast-tv:latest - build: https://github.com/technomancer702/nodecast-tv.git#main + image: gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest container_name: nodecast-tv ports: - "3000:3000" From f715203c4ac2445581343aa05bb3e2a40cd587cb Mon Sep 17 00:00:00 2001 From: blomios Date: Wed, 30 Sep 2026 08:46:09 +0200 Subject: [PATCH 04/36] chore: expose /dev/dri for hardware-accelerated transcoding in docker-compose --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 5487586..06685d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,5 @@ services: environment: - NODE_ENV=production - PORT=3000 # Internal container port + devices: + - /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux) From 4488914b2da020967f77ca62e456b38915cc46dd Mon Sep 17 00:00:00 2001 From: blomios Date: Wed, 30 Sep 2026 12:01:08 +0200 Subject: [PATCH 05/36] feat: add quality/transcoding options to live player (#6) Manual quality profiles (source/1080p/720p/480p) in the player overflow menu plus an honest/preparatory Auto contract: adaptive when the HLS manifest exposes multiple levels, single-rendition automatic selection otherwise. - server: transcodeQualityProfiles service and qualityMode session option - player: quality menu, persisted preference, live switch with rollback - tests: npm test script and unit tests for quality profiles --- package.json | 5 +- public/js/components/VideoPlayer.js | 266 +++++++++++++++++++- server/routes/transcode.js | 22 +- server/services/transcodeQualityProfiles.js | 69 +++++ test/transcodeQualityProfiles.test.js | 82 ++++++ 5 files changed, 427 insertions(+), 17 deletions(-) create mode 100644 server/services/transcodeQualityProfiles.js create mode 100644 test/transcodeQualityProfiles.test.js diff --git a/package.json b/package.json index 6bef281..504f49c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "server/index.js", "scripts": { "start": "node server/index.js", - "dev": "node --watch server/index.js" + "dev": "node --watch server/index.js", + "test": "node --test" }, "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", @@ -29,4 +30,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index c62d9f0..51d4f9a 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -28,6 +28,13 @@ class VideoPlayer { this.isUsingProxy = false; this.currentUrl = null; this.settingsLoaded = false; + this.liveQualityPreferenceKey = 'nodecast_tv_live_quality'; + this.requestedQuality = this.loadLiveQualityPreference(); + this.activeQuality = null; + this.availableQualityProfiles = ['auto', 'source', '1080p', '720p', '480p']; + this.qualitySwitchInProgress = false; + this.originalStreamUrl = null; + this.sourceStreamInfo = null; // Settings - start with defaults, load from server async this.settings = this.getDefaultSettings(); @@ -61,6 +68,23 @@ class VideoPlayer { }; } + loadLiveQualityPreference() { + try { + const saved = localStorage.getItem('nodecast_tv_live_quality'); + return ['auto', 'source', '1080p', '720p', '480p'].includes(saved) ? saved : 'auto'; + } catch { + return 'auto'; + } + } + + saveLiveQualityPreference(qualityMode) { + try { + localStorage.setItem(this.liveQualityPreferenceKey, qualityMode); + } catch (err) { + console.warn('[Player] Unable to persist live quality preference:', err.message); + } + } + /** * Load settings from server API */ @@ -340,6 +364,8 @@ class VideoPlayer { const btnOverflow = document.getElementById('btn-overflow'); const overflowMenu = document.getElementById('player-overflow-menu'); + this.initQualityControl(overflowMenu); + btnOverflow?.addEventListener('click', (e) => { e.stopPropagation(); overflowMenu?.classList.toggle('hidden'); @@ -415,6 +441,77 @@ class VideoPlayer { updateVolumeUI(); } + /** + * Add the live quality control to the existing player overflow menu. + */ + initQualityControl(overflowMenu) { + if (!overflowMenu || document.getElementById('player-quality-control')) return; + + const control = document.createElement('div'); + control.id = 'player-quality-control'; + control.setAttribute('role', 'group'); + control.setAttribute('aria-label', 'Qualité vidéo'); + control.style.borderTop = '1px solid var(--glass-border)'; + overflowMenu.style.minWidth = 'min(280px, calc(100vw - 24px))'; + overflowMenu.style.maxHeight = 'min(70vh, 420px)'; + overflowMenu.style.overflowY = 'auto'; + control.innerHTML = ` +
+ Qualité vidéo +
+
+ `; + overflowMenu.prepend(control); + this.renderQualityControl(); + } + + qualityLabel(mode) { + return ({ auto: 'Auto', source: 'Originale', '1080p': '1080p', '720p': '720p', '480p': '480p' })[mode] || mode; + } + + renderQualityControl() { + const list = document.getElementById('player-quality-options'); + const summary = document.getElementById('player-quality-summary'); + if (!list || !summary) return; + + const profiles = this.availableQualityProfiles; + list.innerHTML = ''; + profiles.forEach(mode => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'overflow-menu-item'; + button.dataset.qualityMode = mode; + button.setAttribute('role', 'menuitemradio'); + button.setAttribute('aria-checked', String(this.requestedQuality === mode)); + button.disabled = this.qualitySwitchInProgress; + button.style.justifyContent = 'space-between'; + button.innerHTML = `${this.qualityLabel(mode)}`; + button.addEventListener('click', (event) => { + event.stopPropagation(); + this.changeLiveQuality(mode); + }); + list.appendChild(button); + }); + + if (this.qualitySwitchInProgress) { + summary.textContent = `Passage en ${this.qualityLabel(this.pendingQuality)}…`; + } else if (this.requestedQuality === 'auto') { + const autoKind = this.autoIsAdaptive ? 'adaptatif' : 'choix automatique au démarrage'; + summary.textContent = `Demandée : Auto (${autoKind})${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`; + } else { + summary.textContent = `Demandée : ${this.qualityLabel(this.requestedQuality)}${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`; + } + } + + updateAvailableQualityProfiles(profiles, sourceHeight) { + const ordered = ['auto', 'source', '1080p', '720p', '480p']; + const backendProfiles = Array.isArray(profiles) ? profiles : ordered; + const maxHeights = { '1080p': 1080, '720p': 720, '480p': 480 }; + this.availableQualityProfiles = ordered.filter(mode => backendProfiles.includes(mode) && + (!maxHeights[mode] || !sourceHeight || sourceHeight >= maxHeights[mode])); + this.renderQualityControl(); + } + /** * Toggle fullscreen mode (cross-browser including Safari) */ @@ -623,7 +720,9 @@ class VideoPlayer { width: this.video.videoWidth, height: this.video.videoHeight }; + this.activeQuality = this.getQualityLabel(this.video.videoHeight); this.updateQualityBadge(); + this.renderQualityControl(); } }); @@ -815,7 +914,7 @@ class VideoPlayer { /** * Start a HLS transcode session */ - async startTranscodeSession(url, options = {}) { + async startTranscodeSession(url, options = {}, allowLegacyFallback = true) { this.currentTranscodeSession = null; try { console.log('[Player] Starting HLS transcode session...', options); @@ -824,15 +923,24 @@ class VideoPlayer { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, ...options }) }); - if (!res.ok) throw new Error('Failed to start session'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to start session'); + } const session = await res.json(); this.currentSessionId = session.sessionId; this.currentTranscodeSession = session; + this.activeQuality = session.selectedProfile || options.qualityMode || null; + this.autoIsAdaptive = session.mode === 'adaptive'; + this.updateAvailableQualityProfiles(session.availableProfiles, this.sourceStreamInfo?.height); + this.renderQualityControl(); return session.playlistUrl; } catch (err) { console.error('[Player] Session start failed:', err); - // Fallback to direct transcode if session fails - return `/api/transcode?url=${encodeURIComponent(url)}`; + if (allowLegacyFallback) { + return `/api/transcode?url=${encodeURIComponent(url)}`; + } + throw err; } } @@ -840,15 +948,18 @@ class VideoPlayer { * Stop and cleanup current transcode session */ async stopTranscodeSession() { - if (this.currentSessionId) { - console.log('[Player] Stopping transcode session:', this.currentSessionId); + const sessionId = this.currentSessionId; + if (sessionId) { + console.log('[Player] Stopping transcode session:', sessionId); try { - // Fire and forget cleanup - fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); + await fetch(`/api/transcode/${sessionId}`, { method: 'DELETE' }); } catch (err) { console.error('Failed to stop session:', err); } - this.currentSessionId = null; + // A new session may have started while the DELETE was in flight. + if (this.currentSessionId === sessionId) { + this.currentSessionId = null; + } } } @@ -857,6 +968,7 @@ class VideoPlayer { */ async play(channel, streamUrl) { this.currentChannel = channel; + this.originalStreamUrl = streamUrl; try { // Stop any WatchPage playback (movies/series) before starting Live TV @@ -875,6 +987,13 @@ class VideoPlayer { // Determine if HLS or direct stream this.currentUrl = streamUrl; + this.sourceStreamInfo = null; + + // A persisted manual preference always creates a dedicated transcode session. + if (this.requestedQuality !== 'auto') { + await this.playManualQuality(channel, streamUrl, this.requestedQuality); + return; + } // Forced playback modes take priority over automatic selection below. const hasForcedPlaybackMode = this.settings.forceVideoTranscode || @@ -891,6 +1010,8 @@ class VideoPlayer { // Store probe result for quality badge display this.currentStreamInfo = info; + this.sourceStreamInfo = info; + this.updateAvailableQualityProfiles(null, info.height); this.updateQualityBadge(); // Handle subtitles from probe result @@ -1135,12 +1256,24 @@ class VideoPlayer { this.hls.loadSource(finalUrl); this.hls.attachMedia(this.video); - this.hls.on(Hls.Events.MANIFEST_PARSED, () => { + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + const levels = data?.levels || this.hls.levels || []; + this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1; + this.renderQualityControl(); this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('Autoplay prevented:', e); }); }); + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls?.levels?.[data.level]; + if (level?.height) { + this.activeQuality = this.getQualityLabel(level.height); + this.updateQualityBadge(); + this.renderQualityControl(); + } + }); + // Re-attach error handler for the new Hls instance this.hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { @@ -1240,12 +1373,27 @@ class VideoPlayer { this.hls.loadSource(url); this.hls.attachMedia(this.video); - this.hls.on(Hls.Events.MANIFEST_PARSED, () => { + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + const levels = data?.levels || this.hls.levels || []; + this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1; + if (this.autoIsAdaptive && this.hls.currentLevel >= 0) { + this.activeQuality = this.getQualityLabel(levels[this.hls.currentLevel]?.height); + } + this.renderQualityControl(); this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('Autoplay prevented:', e); }); }); + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls?.levels?.[data.level]; + if (level?.height) { + this.activeQuality = this.getQualityLabel(level.height); + this.updateQualityBadge(); + this.renderQualityControl(); + } + }); + this.hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { // Simple error handling for forced HLS/transcode modes @@ -1255,6 +1403,92 @@ class VideoPlayer { }); } + async playManualQuality(channel, streamUrl, qualityMode, restorePaused = false) { + this.updateTranscodeStatus('transcoding', `Passage en ${this.qualityLabel(qualityMode)}…`); + let effectiveQuality = qualityMode; + if (!this.sourceStreamInfo?.height) { + try { + const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); + if (probeRes.ok) this.sourceStreamInfo = await probeRes.json(); + } catch (err) { + console.warn('[Player] Could not probe source quality:', err.message); + } + } + if (this.sourceStreamInfo?.height) { + this.updateAvailableQualityProfiles(null, this.sourceStreamInfo.height); + const requestedHeight = Number.parseInt(qualityMode, 10); + if (requestedHeight > this.sourceStreamInfo.height) { + effectiveQuality = ['1080p', '720p', '480p'] + .find(mode => Number.parseInt(mode, 10) <= this.sourceStreamInfo.height) || 'source'; + } + } + const playlistUrl = await this.startTranscodeSession(streamUrl, { + qualityMode: effectiveQuality, + videoMode: effectiveQuality === 'source' ? undefined : 'encode', + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }, false); + this.currentUrl = playlistUrl; + this.playHls(playlistUrl); + if (restorePaused) { + this.video.addEventListener('playing', () => this.video.pause(), { once: true }); + } + this.video.addEventListener('canplay', () => { + this.updateTranscodeStatus('transcoding', this.currentTranscodeSession?.hardwareFallbackOccurred ? 'CPU Fallback' : 'Transcodage'); + }, { once: true }); + this.updateQualityBadge(); + this.updateNowPlaying(channel); + this.showNowPlayingOverlay(); + this.fetchEpgData(channel); + window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel })); + } + + async changeLiveQuality(qualityMode) { + if (!this.currentChannel || !this.originalStreamUrl || this.qualitySwitchInProgress || qualityMode === this.requestedQuality) return; + + const previousQuality = this.requestedQuality; + const wasPaused = this.video.paused; + this.pendingQuality = qualityMode; + this.qualitySwitchInProgress = true; + this.renderQualityControl(); + this.loadingSpinner?.classList.add('show'); + + try { + await this.stopTranscodeSession(); + if (this.hls) { + this.hls.destroy(); + this.hls = null; + } + this.requestedQuality = qualityMode; + this.saveLiveQualityPreference(qualityMode); + + if (qualityMode === 'auto') { + await this.play(this.currentChannel, this.originalStreamUrl); + if (wasPaused) this.video.addEventListener('playing', () => this.video.pause(), { once: true }); + } else { + await this.playManualQuality(this.currentChannel, this.originalStreamUrl, qualityMode, wasPaused); + } + } catch (err) { + console.error('[Player] Quality switch failed, restoring previous quality:', err); + this.requestedQuality = previousQuality; + this.saveLiveQualityPreference(previousQuality); + try { + if (previousQuality === 'auto') { + await this.play(this.currentChannel, this.originalStreamUrl); + } else { + await this.playManualQuality(this.currentChannel, this.originalStreamUrl, previousQuality, wasPaused); + } + } catch (restoreError) { + console.error('[Player] Failed to restore previous quality:', restoreError); + this.showError('Impossible de changer la qualité vidéo. Réessayez.'); + } + } finally { + this.pendingQuality = null; + this.qualitySwitchInProgress = false; + this.loadingSpinner?.classList.remove('show'); + this.renderQualityControl(); + } + } + async updateTranscodeStatus(mode, text) { const el = document.getElementById('player-transcode-status'); if (!el) return; @@ -1293,7 +1527,12 @@ class VideoPlayer { const badge = document.getElementById('player-quality-badge'); if (!badge) return; - if (targetBitrateKbps) { + if (this.requestedQuality && this.activeQuality) { + badge.textContent = this.requestedQuality === 'auto' + ? `Auto · ${this.qualityLabel(this.activeQuality)}` + : `${this.qualityLabel(this.requestedQuality)} · actif ${this.qualityLabel(this.activeQuality)}`; + badge.classList.remove('hidden'); + } else if (targetBitrateKbps) { const targetMbps = targetBitrateKbps / 1000; badge.textContent = `${Number.isInteger(targetMbps) ? targetMbps : targetMbps.toFixed(1)} Mbps`; badge.classList.remove('hidden'); @@ -1453,6 +1692,9 @@ class VideoPlayer { // Hide quality badge this.currentStreamInfo = null; + this.activeQuality = null; + this.autoIsAdaptive = false; + this.renderQualityControl(); const badge = document.getElementById('player-quality-badge'); if (badge) badge.classList.add('hidden'); } diff --git a/server/routes/transcode.js b/server/routes/transcode.js index b11ef57..8f7e5a7 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -5,6 +5,7 @@ const path = require('path'); const fs = require('fs').promises; const db = require('../db'); const transcodeSession = require('../services/transcodeSession'); +const { QUALITY_MODES, resolveQualityMode } = require('../services/transcodeQualityProfiles'); /** * Transcode Routes @@ -31,7 +32,8 @@ transcodeSession.startCleanupInterval(); router.post('/session', async (req, res) => { const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, - targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback + targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback, + qualityMode } = req.body; if (!url) { @@ -43,6 +45,7 @@ router.post('/session', async (req, res) => { const userAgent = db.getUserAgent(settings); try { + const qualitySelection = resolveQualityMode(qualityMode, settings); const session = await transcodeSession.createSession(url, { ffmpegPath, userAgent, @@ -61,7 +64,8 @@ router.post('/session', async (req, res) => { videoMode: videoMode, // 'copy' or 'encode' videoCodec: videoCodec, // 'h264', 'hevc', etc. audioCodec: audioCodec, // 'aac', 'ac3', etc. - audioChannels: audioChannels // number of channels (2=stereo) + audioChannels: audioChannels, // number of channels (2=stereo) + ...(qualitySelection?.sessionOptions || {}) }); await session.start(); @@ -80,10 +84,22 @@ router.post('/session', async (req, res) => { status: session.status, requestedEncoder: session.requestedEncoder, effectiveEncoder: session.effectiveEncoder, - hardwareFallbackOccurred: session.hardwareFallbackOccurred + hardwareFallbackOccurred: session.hardwareFallbackOccurred, + ...(qualitySelection && { + qualityMode: qualitySelection.qualityMode, + mode: qualitySelection.mode, + selectedProfile: qualitySelection.selectedProfile, + availableProfiles: qualitySelection.availableProfiles + }) }); } catch (err) { + if (err.code === 'INVALID_QUALITY_MODE') { + return res.status(400).json({ + error: err.message, + availableProfiles: QUALITY_MODES + }); + } console.error('[Transcode] Session creation failed:', err); res.status(500).json({ error: 'Failed to create session', details: err.message }); } diff --git a/server/services/transcodeQualityProfiles.js b/server/services/transcodeQualityProfiles.js new file mode 100644 index 0000000..13ac145 --- /dev/null +++ b/server/services/transcodeQualityProfiles.js @@ -0,0 +1,69 @@ +const QUALITY_PROFILES = Object.freeze({ + source: Object.freeze({ + id: 'source', + videoMode: 'copy' + }), + '1080p': Object.freeze({ + id: '1080p', + videoMode: 'encode', + maxResolution: '1080p', + targetVideoBitrateKbps: 6000 + }), + '720p': Object.freeze({ + id: '720p', + videoMode: 'encode', + maxResolution: '720p', + targetVideoBitrateKbps: 3500 + }), + '480p': Object.freeze({ + id: '480p', + videoMode: 'encode', + maxResolution: '480p', + targetVideoBitrateKbps: 1500 + }) +}); + +const QUALITY_MODES = Object.freeze(['auto', ...Object.keys(QUALITY_PROFILES)]); + +function getAutomaticProfile(settings = {}) { + const configuredResolution = settings.maxResolution; + return QUALITY_PROFILES[configuredResolution] || QUALITY_PROFILES['1080p']; +} + +function resolveQualityMode(qualityMode, settings = {}) { + if (qualityMode === undefined || qualityMode === null || qualityMode === '') { + return null; + } + + if (!QUALITY_MODES.includes(qualityMode)) { + const error = new Error(`Unsupported qualityMode: ${qualityMode}`); + error.code = 'INVALID_QUALITY_MODE'; + throw error; + } + + const profile = qualityMode === 'auto' + ? getAutomaticProfile(settings) + : QUALITY_PROFILES[qualityMode]; + + return { + qualityMode, + mode: 'single', + selectedProfile: profile.id, + availableProfiles: QUALITY_MODES, + sessionOptions: { + videoMode: profile.videoMode, + ...(profile.maxResolution && { maxResolution: profile.maxResolution }), + ...(profile.targetVideoBitrateKbps && { + targetVideoBitrateKbps: profile.targetVideoBitrateKbps + }), + // A manual downscale profile must not inherit a global upscaling setting. + ...(profile.videoMode === 'encode' && { upscaleEnabled: false }) + } + }; +} + +module.exports = { + QUALITY_MODES, + QUALITY_PROFILES, + resolveQualityMode +}; diff --git a/test/transcodeQualityProfiles.test.js b/test/transcodeQualityProfiles.test.js new file mode 100644 index 0000000..0fb9fb8 --- /dev/null +++ b/test/transcodeQualityProfiles.test.js @@ -0,0 +1,82 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + QUALITY_MODES, + resolveQualityMode +} = require('../server/services/transcodeQualityProfiles'); +const { TranscodeSession } = require('../server/services/transcodeSession'); + +test('legacy payloads do not receive profile overrides', () => { + assert.equal(resolveQualityMode(undefined, { maxResolution: '720p' }), null); +}); + +test('manual quality profiles map to encoding height and bitrate', () => { + const expected = { + '1080p': { maxResolution: '1080p', targetVideoBitrateKbps: 6000 }, + '720p': { maxResolution: '720p', targetVideoBitrateKbps: 3500 }, + '480p': { maxResolution: '480p', targetVideoBitrateKbps: 1500 } + }; + + for (const [qualityMode, profile] of Object.entries(expected)) { + const selection = resolveQualityMode(qualityMode); + assert.equal(selection.mode, 'single'); + assert.equal(selection.selectedProfile, qualityMode); + assert.deepEqual(selection.availableProfiles, QUALITY_MODES); + assert.deepEqual(selection.sessionOptions, { + videoMode: 'encode', + ...profile, + upscaleEnabled: false + }); + } +}); + +test('profile options produce the expected FFmpeg scale and bitrate arguments', () => { + for (const qualityMode of ['1080p', '720p', '480p']) { + const selection = resolveQualityMode(qualityMode); + const session = new TranscodeSession('https://example.test/live.ts', { + hwEncoder: 'software', + audioMixPreset: 'passthrough', + ...selection.sessionOptions + }); + const args = session.buildFFmpegArgs('software'); + const bitrate = selection.sessionOptions.targetVideoBitrateKbps; + + assert.equal(args[args.indexOf('-vf') + 1], `scale=-2:${qualityMode.replace('p', '')}`); + assert.equal(args[args.indexOf('-b:v') + 1], `${bitrate}k`); + assert.equal(args[args.indexOf('-maxrate') + 1], `${bitrate}k`); + assert.equal(args[args.indexOf('-bufsize') + 1], `${bitrate * 2}k`); + } +}); + +test('source profile preserves the original video stream', () => { + const selection = resolveQualityMode('source'); + + assert.equal(selection.selectedProfile, 'source'); + assert.deepEqual(selection.sessionOptions, { videoMode: 'copy' }); +}); + +test('auto selects one configured rendition and reports single-rendition mode', () => { + const selection = resolveQualityMode('auto', { maxResolution: '720p' }); + + assert.equal(selection.qualityMode, 'auto'); + assert.equal(selection.mode, 'single'); + assert.equal(selection.selectedProfile, '720p'); + assert.deepEqual(selection.sessionOptions, { + videoMode: 'encode', + maxResolution: '720p', + targetVideoBitrateKbps: 3500, + upscaleEnabled: false + }); +}); + +test('auto falls back to 1080p for an unsupported global resolution', () => { + assert.equal(resolveQualityMode('auto', { maxResolution: '4k' }).selectedProfile, '1080p'); +}); + +test('unsupported quality modes are rejected', () => { + assert.throws( + () => resolveQualityMode('adaptive'), + error => error.code === 'INVALID_QUALITY_MODE' + ); +}); From e1342348d122fe3da72311ff98589d9e3b0f733a Mon Sep 17 00:00:00 2001 From: blomios Date: Wed, 30 Sep 2026 17:14:05 +0200 Subject: [PATCH 06/36] fix: restore readable contrast in native select dropdowns (#7) --- public/css/main.css | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/public/css/main.css b/public/css/main.css index dedba28..3177580 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1702,6 +1702,44 @@ kbd { border-color: var(--color-accent); } +/* Native select popups do not consistently inherit the application theme. */ +select.source-select, +select.form-input { + color-scheme: dark; + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +select.source-select:focus, +select.form-input:focus { + border-color: var(--color-accent); +} + +select.source-select option, +select.source-select optgroup, +select.form-input option, +select.form-input optgroup { + background-color: #1a1a25; + color: #f1f1f5; +} + +select.source-select option:hover, +select.source-select option:focus, +select.source-select option:checked, +select.form-input option:hover, +select.form-input option:focus, +select.form-input option:checked { + background-color: #2a2a3a; + color: #f1f1f5; +} + +select.source-select option:disabled, +select.source-select optgroup:disabled, +select.form-input option:disabled, +select.form-input optgroup:disabled { + color: #a1a1aa; +} + /* ===================================================== Context Menu ===================================================== */ @@ -4819,4 +4857,4 @@ kbd { font-size: 0.7rem; max-width: 70px; } -} \ No newline at end of file +} From a7a83a6faafc03d20026f550a1fe2ae71fcfae97 Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 08:25:42 +0200 Subject: [PATCH 07/36] feat: group variants of the same IPTV channel into a logical channel (#9) Add a channelVariantGrouping service that normalizes channel names (strips region prefixes and quality tokens) to fingerprint duplicate stream entries and cluster them under a single logical channel with quality/source variants. Wire it into the Live TV channel list and player variant selection, reducing duplicates without destructively merging streams. Co-Authored-By: Claude Opus 4.8 --- public/css/main.css | 54 +++++ public/index.html | 3 +- public/js/components/ChannelList.js | 241 +++++++++---------- public/js/components/VideoPlayer.js | 22 +- public/js/pages/LivePage.js | 5 +- public/js/services/channelVariantGrouping.js | 111 +++++++++ test/channelVariantGrouping.test.js | 44 ++++ 7 files changed, 349 insertions(+), 131 deletions(-) create mode 100644 public/js/services/channelVariantGrouping.js create mode 100644 test/channelVariantGrouping.test.js diff --git a/public/css/main.css b/public/css/main.css index 3177580..aa4627a 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -667,6 +667,60 @@ h4 { text-overflow: ellipsis; } +.variant-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + background: transparent; + color: var(--color-text-muted); + font-size: 0.72rem; + white-space: nowrap; + cursor: pointer; +} + +.logical-channel.expanded .variant-chevron { + transform: rotate(180deg); +} + +.channel-variants { + margin: 0 var(--space-md) var(--space-xs) 56px; + border-left: 1px solid var(--color-border); +} + +.channel-variant { + width: 100%; + display: grid; + grid-template-columns: minmax(52px, auto) minmax(64px, auto) 1fr; + gap: var(--space-sm); + padding: 6px var(--space-sm); + border: 0; + background: transparent; + color: var(--color-text-muted); + text-align: left; + cursor: pointer; +} + +.channel-variant:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.channel-variant.is-hidden { + opacity: 0.5; +} + +.variant-quality { + color: var(--color-accent); + font-weight: 600; +} + +.variant-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ===================================================== Video Player Section ===================================================== */ diff --git a/public/index.html b/public/index.html index 5f89d4a..1554b27 100644 --- a/public/index.html +++ b/public/index.html @@ -1125,7 +1125,8 @@ - + + diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 5147892..a8f3f61 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -23,6 +23,9 @@ class ChannelList { this.sources = []; this.isLoading = false; this.renderedChannels = []; + this.logicalChannels = []; + this.selectedVariants = new Map(); + this.expandedVariants = new Set(); this.loadCollapsedState(); this.init(); @@ -216,14 +219,7 @@ class ChannelList { // Find all visible channel items and update their program info const channelItems = this.container.querySelectorAll('.channel-item'); channelItems.forEach(item => { - const channelId = item.dataset.channelId; - const sourceId = item.dataset.sourceId; - - // Find the channel data - const channel = this.channels.find(c => - String(c.id) === String(channelId) && - String(c.sourceId) === String(sourceId) - ); + const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId); if (channel) { const programInfo = this.getProgramInfo(channel); @@ -246,7 +242,8 @@ class ChannelList { // Cache key: channel_id + current_minute (invalidate every minute) const currentMinute = Math.floor(Date.now() / 60000); - const cacheKey = `${channel.tvgId || channel.name}:${currentMinute}`; + const variant = channel.preferredVariant || channel; + const cacheKey = `${variant.tvgId || variant.name}:${currentMinute}`; if (this._programInfoCache && this._programInfoCache.has(cacheKey)) { return this._programInfoCache.get(cacheKey); @@ -258,7 +255,13 @@ class ChannelList { this._lastCacheMinute = currentMinute; } - const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name); + let program = window.app.epgGuide.getCurrentProgram(variant.tvgId, variant.name); + if (!program && channel.variants) { + for (const fallback of channel.variants) { + program = window.app.epgGuide.getCurrentProgram(fallback.tvgId, fallback.name); + if (program) break; + } + } const result = program ? program.title : null; this._programInfoCache.set(cacheKey, result); @@ -296,6 +299,13 @@ class ChannelList { const searchTerm = this.searchInput.value.toLowerCase(); const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false; + this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, { + showHidden, + selectedVariants: this.selectedVariants, + isFavorite: channel => this.isFavorite(channel.sourceId, channel.id), + isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id) + }); + // Reset batching this.currentBatch = 0; this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups) @@ -305,11 +315,12 @@ class ChannelList { const groupedChannels = {}; // 1. Filter - this.filteredChannels = this.channels; + this.filteredChannels = this.logicalChannels; if (searchTerm) { - this.filteredChannels = this.channels.filter(ch => - String(ch.name ?? "").toLowerCase().includes(searchTerm) || - String(ch.groupTitle ?? "").toLowerCase().includes(searchTerm) + this.filteredChannels = this.logicalChannels.filter(ch => + String(ch.displayName ?? '').toLowerCase().includes(searchTerm) || + ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) || + ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm)) ); } @@ -325,7 +336,7 @@ class ChannelList { }); // 3. Add Favorites - const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id)); + const favoritedChannels = this.logicalChannels.filter(ch => ch.isFavorite); if (favoritedChannels.length > 0) { favoritedChannels.sort((a, b) => a.name.localeCompare(b.name)); groupedChannels['Favorites'] = favoritedChannels; @@ -344,9 +355,7 @@ class ChannelList { const channels = groupedChannels[groupName]; // Check if any channel in this group is visible return channels.some(channel => { - const rawChannelId = channel.streamId || channel.id; - const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !isHidden || showHidden; + return !channel.isHidden || showHidden; }); }); @@ -373,9 +382,7 @@ class ChannelList { const visibleChannels = channels.filter(channel => { if (isFavoritesGroup) return true; - const rawChannelId = channel.streamId || channel.id; - const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !channelHidden || this.showHidden; + return !channel.isHidden || this.showHidden; }); // Assign unique render IDs for linear navigation @@ -454,9 +461,7 @@ class ChannelList { // Pre-filter visible channels for this group const visibleChannels = channels.filter(channel => { if (isFavoritesGroup) return true; - const rawChannelId = channel.streamId || channel.id; - const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !channelHidden || this.showHidden; + return !channel.isHidden || this.showHidden; }); // Skip group if no visible channels (derived visibility) @@ -487,39 +492,14 @@ class ChannelList { for (const channel of visibleChannels) { - // Check hidden again for styling (showHidden mode) - const rawChannelId = channel.streamId || channel.id; - const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId); - const isActive = this.currentChannel?.id === channel.id; // Check if this specific instance is the "active" one for navigation purposes const isRenderActive = this.currentRenderId && this.renderedChannels[renderIndex]?._renderId === this.currentRenderId; - const isFavorite = this.isFavorite(channel.sourceId, channel.id); const renderId = this.renderedChannels[renderIndex]?._renderId || ''; const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName; renderIndex++; - - html += ` -
- -
-
${this.escapeHtml(channel.name)}
-
${this.escapeHtml(this.getProgramInfo(channel) || '')}
-
- -
- `; + html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive, isRenderActive); } html += '
'; } @@ -565,9 +545,13 @@ class ChannelList { header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset)); } + this.attachChannelListeners(groupEl); + } + + attachChannelListeners(groupEl) { groupEl.querySelectorAll('.channel-item').forEach(item => { item.addEventListener('click', (e) => { - if (e.target.closest('.favorite-btn')) return; + if (e.target.closest('.favorite-btn') || e.target.closest('.variant-toggle')) return; this.selectChannel(item.dataset); }); item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset)); @@ -576,10 +560,51 @@ class ChannelList { if (favBtn) { favBtn.addEventListener('click', (e) => { e.stopPropagation(); - this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId); + this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.variantId); }); } }); + groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => { + e.stopPropagation(); + const fingerprint = button.closest('.channel-item').dataset.logicalId; + this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint); + this.render(); + })); + groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => { + e.stopPropagation(); + this.selectChannel(item.dataset); + })); + } + + renderLogicalChannel(channel, renderId, renderGroup, isActive = false, isRenderActive = false) { + const selected = channel.preferredVariant; + const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id); + const expanded = this.expandedVariants.has(channel.fingerprint); + const variants = channel.variants.map(variant => { + const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id); + if (hidden && !this.showHidden) return ''; + const source = this.sources.find(item => String(item.id) === String(variant.sourceId)); + return ``; + }).join(''); + return `
+
+ +
${this.escapeHtml(channel.displayName)}
+
${this.escapeHtml(this.getProgramInfo(channel) || '')}
+ ${channel.variants.length > 1 ? `` : ''} + +
+ ${channel.variants.length > 1 ? `
${variants}
` : ''} +
`; } /** @@ -594,17 +619,12 @@ class ChannelList { // Filter visible channels const visibleChannels = channels.filter(channel => { if (isFavoritesGroup) return true; - const rawChannelId = channel.streamId || channel.id; - const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !channelHidden || this.showHidden; + return !channel.isHidden || this.showHidden; }); let html = ''; for (const channel of visibleChannels) { - const rawChannelId = channel.streamId || channel.id; - const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId); const isActive = this.currentChannel?.id === channel.id; - const isFavorite = this.isFavorite(channel.sourceId, channel.id); // Find the matching rendered channel to get its unique IDs const renderedChannel = this.renderedChannels.find(rc => @@ -613,46 +633,12 @@ class ChannelList { const renderId = renderedChannel?._renderId || ''; const renderGroup = renderedChannel?._renderGroup || groupName; - html += ` -
- -
-
${this.escapeHtml(channel.name)}
-
${this.escapeHtml(this.getProgramInfo(channel) || '')}
-
- -
- `; + html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive); } container.innerHTML = html; - // Attach listeners to the new channel items - container.querySelectorAll('.channel-item').forEach(item => { - item.addEventListener('click', (e) => { - if (e.target.closest('.favorite-btn')) return; - this.selectChannel(item.dataset); - }); - item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset)); - - const favBtn = item.querySelector('.favorite-btn'); - if (favBtn) { - favBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId); - }); - } - }); + this.attachChannelListeners(container); } /** @@ -927,6 +913,7 @@ class ChannelList { if (channel) { this.updateFavoritesGroup(channel, !wasFavorite); } + this.render(); // Do NOT call this.render() - it causes lag // Perform API call @@ -962,6 +949,7 @@ class ChannelList { const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId); if (channel) this.updateFavoritesGroup(channel, false); } + this.render(); } } @@ -1074,10 +1062,21 @@ class ChannelList { * Select and play a channel */ async selectChannel(dataset) { - const channel = this.channels.find(c => c.id === dataset.channelId); - if (!channel) return; + const logicalChannel = this.logicalChannels.find(c => c.fingerprint === dataset.logicalId) || + this.renderedChannels.find(c => c.fingerprint === dataset.logicalId) || + this.logicalChannels.find(c => c.variants.some(variant => + String(variant.id) === String(dataset.channelId) && + (!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId)) + )); + if (!logicalChannel) return; + const channel = logicalChannel.variants.find(variant => + String(variant.id) === String(dataset.variantId || dataset.channelId) && + (!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId)) + ) || logicalChannel.preferredVariant; + this.selectedVariants.set(logicalChannel.fingerprint, ChannelVariantGrouping.getVariantKey(channel)); + logicalChannel.preferredVariant = channel; - this.currentChannel = channel; + this.currentChannel = logicalChannel; this.currentRenderId = dataset.renderId; // Track which visual instance is active this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from @@ -1106,7 +1105,8 @@ class ChannelList { // Fallback checks if still not found if (!activeItem) { - activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`); + activeItem = Array.from(this.container.querySelectorAll('.channel-item')) + .find(item => item.dataset.logicalId === logicalChannel.fingerprint); // If we fell back to channel ID, update currentRenderId to match what we found if (activeItem && activeItem.dataset.renderId) { this.currentRenderId = activeItem.dataset.renderId; @@ -1177,10 +1177,17 @@ class ChannelList { */ showContextMenu(e, type, data) { e.preventDefault(); + const logicalChannel = type === 'channel' + ? this.logicalChannels.find(channel => channel.fingerprint === data.logicalId) + : null; + const variant = logicalChannel?.variants.find(item => + String(item.id) === String(data.variantId) && String(item.sourceId) === String(data.sourceId) + ) || logicalChannel?.preferredVariant; this.contextMenu.dataset.type = type; - this.contextMenu.dataset.sourceId = data.sourceId; - this.contextMenu.dataset.itemId = type === 'group' ? data.group : data.channelId; - this.contextMenu.dataset.streamId = data.streamId || ''; + this.contextMenu.dataset.sourceId = variant?.sourceId ?? data.sourceId; + this.contextMenu.dataset.itemId = type === 'group' ? data.group : (variant?.id || data.variantId); + this.contextMenu.dataset.streamId = variant?.streamId || data.streamId || ''; + this.contextMenu.dataset.logicalId = data.logicalId || ''; this.contextMenu.style.left = `${e.clientX}px`; this.contextMenu.style.top = `${e.clientY}px`; @@ -1199,15 +1206,12 @@ class ChannelList { */ async handleContextAction(e) { const action = e.target.dataset.action; - const { type, sourceId, itemId, streamId } = this.contextMenu.dataset; + const { type, sourceId, itemId, streamId, logicalId } = this.contextMenu.dataset; switch (action) { case 'play': if (type === 'channel') { - const channel = this.channels.find(c => c.id === itemId); - if (channel) { - await this.selectChannel({ channelId: channel.id }); - } + await this.selectChannel({ logicalId, variantId: itemId, sourceId }); } break; case 'hide': @@ -1345,6 +1349,7 @@ class ChannelList { if (channel) { this.updateFavoritesGroup(channel, isFavorite); } + this.render(); } /** @@ -1382,11 +1387,9 @@ class ChannelList { const nextChannel = this.renderedChannels[nextIndex]; this.selectChannel({ - channelId: nextChannel.id, - sourceId: nextChannel.sourceId, - sourceType: nextChannel.sourceType, - streamId: nextChannel.streamId, - url: nextChannel.url, + logicalId: nextChannel.fingerprint, + variantId: nextChannel.preferredVariant.id, + sourceId: nextChannel.preferredVariant.sourceId, renderId: nextChannel._renderId // Pass the unique render ID }); } @@ -1425,11 +1428,9 @@ class ChannelList { const prevChannel = this.renderedChannels[prevIndex]; this.selectChannel({ - channelId: prevChannel.id, - sourceId: prevChannel.sourceId, - sourceType: prevChannel.sourceType, - streamId: prevChannel.streamId, - url: prevChannel.url, + logicalId: prevChannel.fingerprint, + variantId: prevChannel.preferredVariant.id, + sourceId: prevChannel.preferredVariant.sourceId, renderId: prevChannel._renderId }); } @@ -1449,13 +1450,7 @@ class ChannelList { * Get list of visible (non-hidden) channels in display order */ getVisibleChannels() { - const showHidden = this.showHiddenCheckbox?.checked ?? false; - return this.channels.filter(ch => { - if (showHidden) return true; - const channelHidden = this.isHidden('channel', ch.sourceId, ch.id); - const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle); - return !channelHidden && !groupHidden; - }); + return this.logicalChannels; } } diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 51d4f9a..166fed5 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -1819,11 +1819,18 @@ class VideoPlayer { if (channels.length === 0) return; const currentIdx = this.currentChannel - ? channels.findIndex(c => c.id === this.currentChannel.id) + ? channels.findIndex(c => c.variants.some(variant => + variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId + )) : -1; const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1; - window.app.channelList.selectChannel({ channelId: channels[prevIdx].id }); + const previous = channels[prevIdx]; + window.app.channelList.selectChannel({ + logicalId: previous.fingerprint, + variantId: previous.preferredVariant.id, + sourceId: previous.preferredVariant.sourceId + }); } /** @@ -1835,11 +1842,18 @@ class VideoPlayer { if (channels.length === 0) return; const currentIdx = this.currentChannel - ? channels.findIndex(c => c.id === this.currentChannel.id) + ? channels.findIndex(c => c.variants.some(variant => + variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId + )) : -1; const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1; - window.app.channelList.selectChannel({ channelId: channels[nextIdx].id }); + const next = channels[nextIdx]; + window.app.channelList.selectChannel({ + logicalId: next.fingerprint, + variantId: next.preferredVariant.id, + sourceId: next.preferredVariant.sourceId + }); } /** diff --git a/public/js/pages/LivePage.js b/public/js/pages/LivePage.js index 908840c..b61238e 100644 --- a/public/js/pages/LivePage.js +++ b/public/js/pages/LivePage.js @@ -36,7 +36,7 @@ class LivePage { // Build a map for O(1) channel lookups const channelMap = new Map(); - this.app.channelList.channels.forEach(c => channelMap.set(c.id, c)); + this.app.channelList.logicalChannels.forEach(c => channelMap.set(c.fingerprint, c)); // Process in small batches to avoid blocking UI const BATCH_SIZE = 50; @@ -47,8 +47,7 @@ class LivePage { for (let i = index; i < end; i++) { const item = channelItems[i]; - const channelId = item.dataset.channelId; - const channel = channelMap.get(channelId); + const channel = channelMap.get(item.dataset.logicalId); if (channel) { const programDiv = item.querySelector('.channel-program'); diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js new file mode 100644 index 0000000..ace2767 --- /dev/null +++ b/public/js/services/channelVariantGrouping.js @@ -0,0 +1,111 @@ +(function (root, factory) { + const service = factory(); + if (typeof module === 'object' && module.exports) module.exports = service; + if (root) root.ChannelVariantGrouping = service; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + const QUALITY_TOKENS = new Set([ + '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', + '1080p', '1080i', '720p', '576p', '480p' + ]); + + const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i; + + function words(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(REGION_PREFIX, '') + .replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ') + .split(/\s+/) + .filter(Boolean); + } + + function normalizeChannelName(name) { + return words(name).filter(token => !QUALITY_TOKENS.has(token)).join(' ').trim(); + } + + function displayNameForFingerprint(fingerprint) { + return fingerprint.split(' ').map(token => { + if (token === 'bein') return 'beIN'; + if (/^\d+$/.test(token)) return token; + return token.charAt(0).toUpperCase() + token.slice(1); + }).join(' '); + } + + function getVariantKey(channel) { + return `${channel.sourceId}:${channel.id}`; + } + + function getQuality(channel) { + const tokens = words(channel.name); + if (tokens.includes('4k') || tokens.includes('uhd')) return '4K'; + if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) return 'Full HD'; + if (tokens.includes('hd') || tokens.includes('720p')) return 'HD'; + if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return 'SD'; + return 'Standard'; + } + + function qualityScore(channel) { + return { '4K': 4, 'Full HD': 3, HD: 2, Standard: 1, SD: 0 }[getQuality(channel)] ?? 1; + } + + function choosePreferredVariant(variants, options) { + const selectedKey = options.selectedVariantKey; + const selected = variants.find(variant => getVariantKey(variant) === selectedKey); + if (selected && (options.showHidden || !options.isHidden(selected))) return selected; + + return variants.slice().sort((a, b) => { + const hiddenDifference = Number(options.isHidden(a)) - Number(options.isHidden(b)); + if (!options.showHidden && hiddenDifference) return hiddenDifference; + return qualityScore(b) - qualityScore(a); + })[0]; + } + + function groupChannels(channels, options = {}) { + const isFavorite = options.isFavorite || (() => false); + const isHidden = options.isHidden || (() => false); + const showHidden = Boolean(options.showHidden); + const selectedVariants = options.selectedVariants || new Map(); + const buckets = new Map(); + + channels.forEach(channel => { + const fingerprint = normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`; + if (!buckets.has(fingerprint)) buckets.set(fingerprint, []); + buckets.get(fingerprint).push(channel); + }); + + return Array.from(buckets, ([fingerprint, variants]) => { + const visibleVariants = variants.filter(variant => !isHidden(variant)); + const preferredVariant = choosePreferredVariant(variants, { + isHidden, + showHidden, + selectedVariantKey: selectedVariants.get(fingerprint) + }); + return { + ...preferredVariant, + id: `logical:${fingerprint}`, + fingerprint, + displayName: displayNameForFingerprint(fingerprint), + name: displayNameForFingerprint(fingerprint), + variants, + visibleVariants, + preferredVariant, + selectedVariantId: getVariantKey(preferredVariant), + isFavorite: variants.some(isFavorite), + isHidden: visibleVariants.length === 0, + groupTitle: preferredVariant.groupTitle, + tvgLogo: preferredVariant.tvgLogo, + tvgId: preferredVariant.tvgId + }; + }).filter(channel => showHidden || !channel.isHidden); + } + + return { + normalizeChannelName, + displayNameForFingerprint, + getVariantKey, + getQuality, + groupChannels + }; +}); diff --git a/test/channelVariantGrouping.test.js b/test/channelVariantGrouping.test.js new file mode 100644 index 0000000..6d8ca7c --- /dev/null +++ b/test/channelVariantGrouping.test.js @@ -0,0 +1,44 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const grouping = require('../public/js/services/channelVariantGrouping.js'); + +const channel = (id, name, extra = {}) => ({ id, streamId: id, sourceId: 1, name, groupTitle: 'Sports', ...extra }); + +test('normalizes regional prefixes and quality markers into one logical channel', () => { + const names = ['|FR| BEIN SPORTS uhd 1', '|FR| BEIN SPORTS 1 4K', 'FR-CAR| bein sports 1']; + assert.deepEqual(names.map(grouping.normalizeChannelName), ['bein sports 1', 'bein sports 1', 'bein sports 1']); + const logical = grouping.groupChannels(names.map((name, index) => channel(String(index + 1), name))); + assert.equal(logical.length, 1); + assert.equal(logical[0].displayName, 'beIN Sports 1'); + assert.equal(logical[0].variants.length, 3); +}); + +test('does not merge distinct numbered channels', () => { + const logical = grouping.groupChannels([ + channel('1', 'beIN Sports 1 HD'), + channel('2', 'beIN Sports 2 HD') + ]); + assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']); +}); + +test('logical favorite and hidden state are derived from individual streams', () => { + const variants = [channel('1', 'BEIN SPORTS 1 HD'), channel('2', 'BEIN SPORTS 1 4K')]; + const logical = grouping.groupChannels(variants, { + isFavorite: item => item.id === '1', + isHidden: item => item.id === '2' + })[0]; + assert.equal(logical.isFavorite, true); + assert.equal(logical.isHidden, false); + assert.equal(logical.preferredVariant.id, '1'); + + assert.equal(grouping.groupChannels(variants, { isHidden: () => true }).length, 0); + assert.equal(grouping.groupChannels(variants, { isHidden: () => true, showHidden: true })[0].isHidden, true); +}); + +test('an explicit visible selection overrides the automatic quality preference', () => { + const hd = channel('1', 'BEIN SPORTS 1 HD'); + const uhd = channel('2', 'BEIN SPORTS 1 UHD'); + const selectedVariants = new Map([['bein sports 1', grouping.getVariantKey(hd)]]); + const logical = grouping.groupChannels([hd, uhd], { selectedVariants })[0]; + assert.equal(logical.preferredVariant.id, '1'); +}); From 8a90934472addbfc70a421cc453f3bc6d3844b9d Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 09:10:03 +0200 Subject: [PATCH 08/36] fix: restore local build in docker compose for local code deployment (#9) docker-compose.yml referenced only the Gitea registry image, so `docker compose up --build` ignored local source and never shipped the channel variant grouping feature. Add a build section (root context, local Dockerfile) alongside the registry image tag so compose builds local code while keeping the published image name. Co-Authored-By: Claude Opus 4.8 --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 06685d3..b088f7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,8 @@ services: nodecast-tv: + build: + context: . + dockerfile: Dockerfile image: gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest container_name: nodecast-tv ports: From b95a408071310355128b88d41f45dc7c8e3a54da Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 09:33:28 +0200 Subject: [PATCH 09/36] feat: detect variant language with confidence scoring and player language selector (#10) Add a channelLanguageDetection service that scores language evidence per IPTV variant (country/language prefixes in name and group, source metadata, original title words) and picks the highest-scored language. Integrate detected languages into channel variant grouping and add a language selector to the live player to switch to a variant of the logical channel in the desired language, keeping raw streams non-destructive. Co-Authored-By: Claude Opus 4.8 --- public/css/main.css | 12 ++- public/index.html | 9 +- public/js/components/ChannelList.js | 78 +++++++++++++++-- public/js/components/VideoPlayer.js | 45 ++++++++++ .../js/services/channelLanguageDetection.js | 84 +++++++++++++++++++ public/js/services/channelVariantGrouping.js | 65 ++++++++++++-- test/channelLanguageDetection.test.js | 66 +++++++++++++++ 7 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 public/js/services/channelLanguageDetection.js create mode 100644 test/channelLanguageDetection.test.js diff --git a/public/css/main.css b/public/css/main.css index aa4627a..32f45a1 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -691,7 +691,7 @@ h4 { .channel-variant { width: 100%; display: grid; - grid-template-columns: minmax(52px, auto) minmax(64px, auto) 1fr; + grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr; gap: var(--space-sm); padding: 6px var(--space-sm); border: 0; @@ -715,6 +715,16 @@ h4 { font-weight: 600; } +.variant-language { + justify-self: start; + padding: 1px 5px; + border-radius: 999px; + background: var(--color-bg-tertiary); + color: var(--color-text-muted); + font-size: 0.68rem; + white-space: nowrap; +} + .variant-name { overflow: hidden; text-overflow: ellipsis; diff --git a/public/index.html b/public/index.html index 1554b27..3239c7d 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,7 @@ nodecast-tv | IPTV Player - + - - - + + + + diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index a8f3f61..c708a73 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -26,6 +26,10 @@ class ChannelList { this.logicalChannels = []; this.selectedVariants = new Map(); this.expandedVariants = new Set(); + this.preferredLanguageKey = 'nodecast_tv_preferred_language'; + this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences'; + this.preferredLanguage = this.loadPreferredLanguage(); + this.channelLanguagePreferences = this.loadChannelLanguagePreferences(); this.loadCollapsedState(); this.init(); @@ -62,6 +66,32 @@ class ChannelList { } } + loadPreferredLanguage() { + try { + return localStorage.getItem(this.preferredLanguageKey) || null; + } catch { + return null; + } + } + + loadChannelLanguagePreferences() { + try { + return new Map(Object.entries(JSON.parse(localStorage.getItem(this.channelLanguagePreferencesKey) || '{}'))); + } catch { + return new Map(); + } + } + + saveLanguagePreferences() { + try { + if (this.preferredLanguage) localStorage.setItem(this.preferredLanguageKey, this.preferredLanguage); + else localStorage.removeItem(this.preferredLanguageKey); + localStorage.setItem(this.channelLanguagePreferencesKey, JSON.stringify(Object.fromEntries(this.channelLanguagePreferences))); + } catch (err) { + console.warn('[ChannelList] Unable to persist language preferences:', err.message); + } + } + /** * Save collapsed state to localStorage */ @@ -302,6 +332,8 @@ class ChannelList { this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, { showHidden, selectedVariants: this.selectedVariants, + preferredLanguage: this.preferredLanguage, + languagePreferences: this.channelLanguagePreferences, isFavorite: channel => this.isFavorite(channel.sourceId, channel.id), isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id) }); @@ -320,7 +352,8 @@ class ChannelList { this.filteredChannels = this.logicalChannels.filter(ch => String(ch.displayName ?? '').toLowerCase().includes(searchTerm) || ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) || - ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm)) + ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm)) || + ch.variants.some(variant => `${variant.language.code} ${variant.language.label}`.toLowerCase().includes(searchTerm)) ); } @@ -584,10 +617,18 @@ class ChannelList { const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id); if (hidden && !this.showHidden) return ''; const source = this.sources.find(item => String(item.id) === String(variant.sourceId)); + const language = variant.language; + const languageLabel = language.code === 'unknown' + ? 'Unknown' + : `${language.code.toUpperCase()} ${language.confidence}%`; + const languageDescription = language.code === 'unknown' + ? 'Unknown language' + : `${language.label} — ${language.confidence}% confidence`; return ``; @@ -774,7 +815,8 @@ class ChannelList { id: `xtream_${sourceId}_${cat.category_id}`, name: cat.category_name, sourceId, - sourceType: 'xtream' + sourceType: 'xtream', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.groups = this.groups.concat(categoryGroups); @@ -790,7 +832,8 @@ class ChannelList { // Use string comparison to handle type mismatches (number vs string category_id) groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized', sourceId, - sourceType: 'xtream' + sourceType: 'xtream', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.channels = this.channels.concat(channelList); @@ -815,7 +858,8 @@ class ChannelList { id: `m3u_${sourceId}_${cat.category_id}`, name: cat.category_name, sourceId, - sourceType: 'm3u' + sourceType: 'm3u', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.groups = this.groups.concat(m3uGroups); @@ -831,7 +875,8 @@ class ChannelList { groupId: `m3u_${sourceId}_${stream.category_id}`, groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized', sourceId, - sourceType: 'm3u' + sourceType: 'm3u', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.channels = this.channels.concat(channelList); @@ -1168,10 +1213,33 @@ class ChannelList { // Play channel if (window.app?.player) { + window.app.player.updateLanguageControl?.(logicalChannel); window.app.player.play(channel, streamUrl); } } + async selectLanguageForCurrent(languageCode) { + if (!this.currentChannel) return; + const fingerprint = this.currentChannel.fingerprint; + if (!languageCode || languageCode === 'auto') { + this.channelLanguagePreferences.delete(fingerprint); + this.preferredLanguage = null; + } else { + this.channelLanguagePreferences.set(fingerprint, languageCode); + this.preferredLanguage = languageCode; + } + this.selectedVariants.delete(fingerprint); + this.saveLanguagePreferences(); + this.render(); + const logicalChannel = this.logicalChannels.find(channel => channel.fingerprint === fingerprint); + if (!logicalChannel) return; + await this.selectChannel({ + logicalId: fingerprint, + variantId: logicalChannel.preferredVariant.id, + sourceId: logicalChannel.preferredVariant.sourceId + }); + } + /** * Show context menu */ diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 166fed5..198e3ae 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -35,6 +35,7 @@ class VideoPlayer { this.qualitySwitchInProgress = false; this.originalStreamUrl = null; this.sourceStreamInfo = null; + this.currentLogicalChannel = null; // Settings - start with defaults, load from server async this.settings = this.getDefaultSettings(); @@ -365,6 +366,7 @@ class VideoPlayer { const overflowMenu = document.getElementById('player-overflow-menu'); this.initQualityControl(overflowMenu); + this.initLanguageControl(overflowMenu); btnOverflow?.addEventListener('click', (e) => { e.stopPropagation(); @@ -465,6 +467,49 @@ class VideoPlayer { this.renderQualityControl(); } + initLanguageControl(overflowMenu) { + if (!overflowMenu || document.getElementById('player-language-control')) return; + const control = document.createElement('div'); + control.id = 'player-language-control'; + control.hidden = true; + control.setAttribute('role', 'group'); + control.setAttribute('aria-label', 'Language'); + control.style.borderTop = '1px solid var(--glass-border)'; + control.innerHTML = ` +
Language
+ `; + overflowMenu.append(control); + } + + updateLanguageControl(logicalChannel) { + this.currentLogicalChannel = logicalChannel; + const control = document.getElementById('player-language-control'); + const list = document.getElementById('player-language-options'); + if (!control || !list) return; + const languages = logicalChannel?.availableLanguages || []; + control.hidden = languages.length < 2; + list.innerHTML = ''; + if (control.hidden) return; + + const channelList = window.app?.channelList; + const selected = channelList?.channelLanguagePreferences?.get(logicalChannel.fingerprint) || + channelList?.preferredLanguage || 'auto'; + [{ code: 'auto', label: 'Auto' }, ...languages].forEach(language => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'overflow-menu-item'; + button.setAttribute('role', 'menuitemradio'); + button.setAttribute('aria-checked', String(selected === language.code)); + button.style.justifyContent = 'space-between'; + button.innerHTML = `${language.label}`; + button.addEventListener('click', async event => { + event.stopPropagation(); + await window.app?.channelList?.selectLanguageForCurrent(language.code); + }); + list.appendChild(button); + }); + } + qualityLabel(mode) { return ({ auto: 'Auto', source: 'Originale', '1080p': '1080p', '720p': '720p', '480p': '480p' })[mode] || mode; } diff --git a/public/js/services/channelLanguageDetection.js b/public/js/services/channelLanguageDetection.js new file mode 100644 index 0000000..5d3f462 --- /dev/null +++ b/public/js/services/channelLanguageDetection.js @@ -0,0 +1,84 @@ +(function (root, factory) { + const service = factory(); + if (typeof module === 'object' && module.exports) module.exports = service; + if (root) root.ChannelLanguageDetection = service; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + const LANGUAGES = { + fr: { code: 'fr', label: 'French' }, + en: { code: 'en', label: 'English' }, + es: { code: 'es', label: 'Spanish' } + }; + const UNKNOWN = { code: 'unknown', label: 'Unknown', confidence: 0, status: 'unknown' }; + const EXPLICIT_CODES = { FR: 'fr', SFR: 'fr', 'FR-CAR': 'fr', EN: 'en', UK: 'en', US: 'en', AU: 'en', ES: 'es' }; + const WORDS = { + fr: ['french', 'francais', 'france'], + en: ['english', 'anglais', 'england', 'british'], + es: ['spanish', 'espagnol', 'espanol', 'spain'] + }; + + function normalized(value) { + return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + } + + function addEvidence(scores, evidence, code, weight, type, value) { + if (!code) return; + scores[code] = (scores[code] || 0) + weight; + evidence.push({ code, weight, type, value: String(value || '') }); + } + + function findExplicitPrefix(name) { + const match = String(name || '').trim().match(/^(?:\|\s*([A-Z-]{2,8})\s*\||\[\s*([A-Z-]{2,8})\s*\]|([A-Z-]{2,8})\s*\|)/i); + return match ? (match[1] || match[2] || match[3]).toUpperCase() : null; + } + + function scoreText(scores, evidence, value, weight, type, includeCountryCodes) { + const text = normalized(value); + if (!text) return; + Object.entries(WORDS).forEach(([code, terms]) => { + if (terms.some(term => new RegExp(`(?:^|[^a-z])${term}(?:$|[^a-z])`, 'i').test(text))) { + addEvidence(scores, evidence, code, weight, type, value); + } + }); + if (includeCountryCodes) { + const tokens = text.toUpperCase().split(/[^A-Z]+/).filter(Boolean); + const mapped = tokens.map(token => EXPLICIT_CODES[token]).filter(Boolean); + [...new Set(mapped)].forEach(code => addEvidence(scores, evidence, code, weight, type, value)); + } + } + + function detectLanguage(variant, context = {}) { + const scores = {}; + const evidence = []; + const prefix = findExplicitPrefix(variant.name); + addEvidence(scores, evidence, EXPLICIT_CODES[prefix], 60, 'name-prefix', prefix); + scoreText(scores, evidence, variant.name, 45, 'name-token', false); + scoreText(scores, evidence, variant.groupTitle, 35, 'group', true); + scoreText(scores, evidence, context.sourceName || variant.sourceName, 20, 'source', true); + scoreText(scores, evidence, `${variant.tvgId || ''} ${variant.tvgName || ''}`, 20, 'metadata', true); + + const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]); + const total = ranked.reduce((sum, entry) => sum + entry[1], 0); + const percentages = Object.fromEntries(ranked.map(([code, score]) => [code, Math.round(score / total * 100)])); + if (!ranked.length || total < 20) { + return { language: { ...UNKNOWN }, languageScores: percentages, languageEvidence: evidence }; + } + + const [topCode, topScore] = ranked[0]; + const topConfidence = percentages[topCode]; + const secondConfidence = ranked[1] ? percentages[ranked[1][0]] : 0; + if (topScore < 20 || topConfidence < 45 || topConfidence - secondConfidence < 10) { + return { language: { ...UNKNOWN }, languageScores: percentages, languageEvidence: evidence }; + } + + let status = 'low'; + if (topScore >= 60 && topConfidence >= 75) status = 'high'; + else if (topScore >= 35 && topConfidence >= 60) status = 'medium'; + return { + language: { ...LANGUAGES[topCode], confidence: topConfidence, status }, + languageScores: percentages, + languageEvidence: evidence + }; + } + + return { LANGUAGES, detectLanguage }; +}); diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js index ace2767..e0bd013 100644 --- a/public/js/services/channelVariantGrouping.js +++ b/public/js/services/channelVariantGrouping.js @@ -1,8 +1,11 @@ (function (root, factory) { - const service = factory(); + const detector = typeof module === 'object' && module.exports + ? require('./channelLanguageDetection.js') + : root.ChannelLanguageDetection; + const service = factory(detector); if (typeof module === 'object' && module.exports) module.exports = service; if (root) root.ChannelVariantGrouping = service; -})(typeof globalThis !== 'undefined' ? globalThis : this, function () { +})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection) { const QUALITY_TOKENS = new Set([ '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', '1080p', '1080i', '720p', '576p', '480p' @@ -55,11 +58,27 @@ const selected = variants.find(variant => getVariantKey(variant) === selectedKey); if (selected && (options.showHidden || !options.isHidden(selected))) return selected; - return variants.slice().sort((a, b) => { + const requestedLanguageCandidate = options.channelLanguagePreference || options.preferredLanguage; + const requestedLanguage = variants.some(variant => variant.language.code === requestedLanguageCandidate) + ? requestedLanguageCandidate + : null; + const majorityLanguage = options.majorityLanguage; + return variants.map((variant, index) => ({ variant, index })).sort((aEntry, bEntry) => { + const a = aEntry.variant; + const b = bEntry.variant; const hiddenDifference = Number(options.isHidden(a)) - Number(options.isHidden(b)); if (!options.showHidden && hiddenDifference) return hiddenDifference; - return qualityScore(b) - qualityScore(a); - })[0]; + if (requestedLanguage && requestedLanguage !== 'auto') { + const languageDifference = Number(b.language.code === requestedLanguage) - Number(a.language.code === requestedLanguage); + if (languageDifference) return languageDifference; + } else if (majorityLanguage) { + const majorityDifference = Number(b.language.code === majorityLanguage) - Number(a.language.code === majorityLanguage); + if (majorityDifference) return majorityDifference; + } + const favoriteDifference = Number(options.isFavorite(b)) - Number(options.isFavorite(a)); + if (favoriteDifference) return favoriteDifference; + return qualityScore(b) - qualityScore(a) || aEntry.index - bEntry.index; + })[0].variant; } function groupChannels(channels, options = {}) { @@ -67,6 +86,7 @@ const isHidden = options.isHidden || (() => false); const showHidden = Boolean(options.showHidden); const selectedVariants = options.selectedVariants || new Map(); + const languagePreferences = options.languagePreferences || new Map(); const buckets = new Map(); channels.forEach(channel => { @@ -75,13 +95,41 @@ buckets.get(fingerprint).push(channel); }); - return Array.from(buckets, ([fingerprint, variants]) => { + return Array.from(buckets, ([fingerprint, rawVariants]) => { + const variants = rawVariants.map(variant => ({ + ...variant, + ...languageDetection.detectLanguage(variant, { sourceName: variant.sourceName }) + })); const visibleVariants = variants.filter(variant => !isHidden(variant)); + const selectableVariants = showHidden ? variants : visibleVariants; + const languageCounts = selectableVariants.reduce((counts, variant) => { + counts[variant.language.code] = (counts[variant.language.code] || 0) + 1; + return counts; + }, {}); + const majorityLanguage = Object.entries(languageCounts) + .filter(([code]) => code !== 'unknown') + .sort((a, b) => b[1] - a[1])[0]?.[0]; const preferredVariant = choosePreferredVariant(variants, { isHidden, + isFavorite, showHidden, - selectedVariantKey: selectedVariants.get(fingerprint) + selectedVariantKey: selectedVariants.get(fingerprint), + channelLanguagePreference: languagePreferences.get(fingerprint), + preferredLanguage: options.preferredLanguage, + majorityLanguage }); + const availableLanguages = [...new Map(selectableVariants.map(variant => [ + variant.language.code, + variant.language + ])).values()]; + const aggregateScores = {}; + selectableVariants.forEach(variant => Object.entries(variant.languageScores).forEach(([code, score]) => { + aggregateScores[code] = (aggregateScores[code] || 0) + score; + })); + const scoreTotal = Object.values(aggregateScores).reduce((sum, score) => sum + score, 0); + const languageScores = Object.fromEntries(Object.entries(aggregateScores).map(([code, score]) => [ + code, Math.round(score / scoreTotal * 100) + ])); return { ...preferredVariant, id: `logical:${fingerprint}`, @@ -91,6 +139,9 @@ variants, visibleVariants, preferredVariant, + language: preferredVariant.language, + languageScores, + availableLanguages, selectedVariantId: getVariantKey(preferredVariant), isFavorite: variants.some(isFavorite), isHidden: visibleVariants.length === 0, diff --git a/test/channelLanguageDetection.test.js b/test/channelLanguageDetection.test.js new file mode 100644 index 0000000..83f8a48 --- /dev/null +++ b/test/channelLanguageDetection.test.js @@ -0,0 +1,66 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const detection = require('../public/js/services/channelLanguageDetection.js'); +const grouping = require('../public/js/services/channelVariantGrouping.js'); + +const channel = (id, name, extra = {}) => ({ + id, streamId: id, sourceId: 1, name, groupTitle: 'Sports', ...extra +}); + +test('scores explicit French prefixes with strong confidence', () => { + for (const name of ['|FR| BEIN SPORTS 1 UHD', 'FR-CAR| bein sports 1']) { + const result = detection.detectLanguage(channel(name, name)); + assert.equal(result.language.code, 'fr'); + assert.match(result.language.status, /medium|high/); + assert.ok(result.languageScores.fr >= 75); + assert.ok(result.languageEvidence.some(item => item.type === 'name-prefix')); + } +}); + +test('maps UK group evidence to English with medium confidence', () => { + const result = detection.detectLanguage(channel('1', 'Sports One', { groupTitle: 'UK Sports' })); + assert.equal(result.language.code, 'en'); + assert.equal(result.language.status, 'medium'); +}); + +test('uses a France source as weak French evidence', () => { + const result = detection.detectLanguage(channel('1', 'Sports One'), { sourceName: 'France IPTV' }); + assert.equal(result.language.code, 'fr'); + assert.equal(result.language.status, 'low'); +}); + +test('does not force multilingual BE to a language', () => { + const result = detection.detectLanguage(channel('1', 'BE Sports 1')); + assert.equal(result.language.code, 'unknown'); +}); + +test('classifies contradictory close evidence as unknown', () => { + const result = detection.detectLanguage(channel('1', 'French English Sports')); + assert.equal(result.language.code, 'unknown'); + assert.equal(result.language.status, 'unknown'); +}); + +test('global and per-channel language preferences resolve tied variants', () => { + const variants = [channel('fr', '|FR| Sports One HD'), channel('en', '|UK| Sports One HD')]; + const globalEnglish = grouping.groupChannels(variants, { preferredLanguage: 'en' })[0]; + assert.equal(globalEnglish.preferredVariant.id, 'en'); + + const perChannel = grouping.groupChannels(variants, { + preferredLanguage: 'en', + languagePreferences: new Map([['sports one', 'fr']]) + })[0]; + assert.equal(perChannel.preferredVariant.id, 'fr'); +}); + +test('explicit language preference beats quality and hidden variants are ignored', () => { + const frenchSd = channel('fr', '|FR| Sports One SD'); + const english4k = channel('en', '|UK| Sports One 4K'); + const preferredFrench = grouping.groupChannels([frenchSd, english4k], { preferredLanguage: 'fr' })[0]; + assert.equal(preferredFrench.preferredVariant.id, 'fr'); + + const hiddenFrench = grouping.groupChannels([frenchSd, english4k], { + preferredLanguage: 'fr', + isHidden: variant => variant.id === 'fr' + })[0]; + assert.equal(hiddenFrench.preferredVariant.id, 'en'); +}); From e06f4ec2c36666e2f6d2d5a12e0457c90495c14b Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 09:57:20 +0200 Subject: [PATCH 10/36] feat: add settings option to hide Live TV categories (#11) Add a liveChannelListMode setting (default 'grouped') with a Settings toggle to display the Live TV channel list flat, without category group sections. When enabled, logical channels are listed directly with their stream variants still grouped as in #9/#10. Cover the setting default and flat-list behavior with tests. Co-Authored-By: Claude Opus 4.8 --- public/index.html | 22 ++++- public/js/components/ChannelList.js | 85 +++++++++++++++++++- public/js/components/VideoPlayer.js | 2 + public/js/pages/Settings.js | 11 +++ public/js/services/channelVariantGrouping.js | 15 +++- server/db.js | 1 + test/channelVariantGrouping.test.js | 17 ++++ test/settingsDefaults.test.js | 7 ++ 8 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 test/settingsDefaults.test.js diff --git a/public/index.html b/public/index.html index 3239c7d..c65bf4e 100644 --- a/public/index.html +++ b/public/index.html @@ -454,6 +454,20 @@
+
+

Live TV

+
+ +
+ Group channels by category + Turn off to show all channels in a single list. Stream variants remain grouped by channel. +
+
+
+

Keyboard Controls

@@ -1124,10 +1138,10 @@ - + - - + + @@ -1135,7 +1149,7 @@ - + - - + + - - + + + @@ -1149,7 +1180,7 @@ - + + @@ -1180,7 +1190,7 @@ - + - + - + @@ -1190,7 +1194,7 @@ - + - + - + diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 6ee47a7..d985acd 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -363,14 +363,19 @@ class ChannelList { const results = new Map(); try { const status = await API.laya.getStatus(); - if (!status?.modelPresent || !status?.runtimeAvailable) { + const statusWarning = LayaStatusPresentation.channelListWarning(mode, status); + if (statusWarning) { if (requestId !== this._languageDetectionRequest) return; this.layaLanguageResults.clear(); this.languageDetectionFallback = true; - this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.'; + this.languageDetectionNotice = statusWarning; this.render(); return; } + if (requestId !== this._languageDetectionRequest) return; + this.languageDetectionNotice = ''; + this.languageDetectionFallback = false; + this.render(); for (let index = 0; index < descriptors.length; index += 64) { const response = await API.laya.detectLanguages(descriptors.slice(index, index + 64)); (response.results || []).forEach(result => results.set(String(result.id), result)); @@ -383,7 +388,7 @@ class ChannelList { if (requestId !== this._languageDetectionRequest) return; this.layaLanguageResults.clear(); this.languageDetectionFallback = true; - this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.'; + this.languageDetectionNotice = LayaStatusPresentation.channelListWarning(mode, {}, true); console.warn('[ChannelList] Local AI language detection unavailable:', err.message); } this.render(); @@ -442,6 +447,7 @@ class ChannelList { window.app?.navigateTo?.('settings'); window.app?.pages?.settings?.switchTab?.('player'); }); + notice.appendChild(document.createTextNode(' ')); notice.appendChild(settingsButton); this.container.appendChild(notice); } diff --git a/public/js/services/layaStatusPresentation.js b/public/js/services/layaStatusPresentation.js index 86327fe..e846bdf 100644 --- a/public/js/services/layaStatusPresentation.js +++ b/public/js/services/layaStatusPresentation.js @@ -77,5 +77,13 @@ }; } - return { presentLayaStatus }; + function channelListWarning(mode, status = {}, batchFailed = false) { + if (mode === 'algo') return ''; + if (batchFailed || status.modelPresent !== true || status.runtimeAvailable !== true) { + return 'Local AI service is unavailable. Standard channel matching is being used.'; + } + return ''; + } + + return { channelListWarning, presentLayaStatus }; }); diff --git a/test/layaStatusPresentation.test.js b/test/layaStatusPresentation.test.js index 685a7bf..e216067 100644 --- a/test/layaStatusPresentation.test.js +++ b/test/layaStatusPresentation.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { presentLayaStatus } = require('../public/js/services/layaStatusPresentation.js'); +const { channelListWarning, presentLayaStatus } = require('../public/js/services/layaStatusPresentation.js'); test('presents unknown-size downloads as indeterminate with downloaded bytes', () => { const view = presentLayaStatus({ @@ -37,9 +37,25 @@ test('failed downloads expose retry, safe details and Standard fallback', () => }); test('ready models expose removal without download actions', () => { - const view = presentLayaStatus({ phase: 'ready', modelPresent: true, runtimeAvailable: true }); + const view = presentLayaStatus({ phase: 'idle', modelPresent: true, runtimeAvailable: true, sizeBytes: 678323814 }); + assert.equal(view.statusLabel, 'Ready'); assert.equal(view.showRemove, true); assert.equal(view.showDownload, false); + assert.equal(view.showCheckAgain, false); + assert.equal(view.showStartCommand, false); + assert.equal(view.availabilityMessage, ''); +}); + +test('ChannelList warning only appears for active Laya modes when unavailable or failed', () => { + const ready = { modelPresent: true, runtimeAvailable: true }; + assert.equal(channelListWarning('hybrid', ready), ''); + assert.equal(channelListWarning('laya', ready), ''); + assert.equal(channelListWarning('algo', { modelPresent: false, runtimeAvailable: false }), ''); + assert.equal( + channelListWarning('hybrid', { modelPresent: true, runtimeAvailable: false }), + 'Local AI service is unavailable. Standard channel matching is being used.' + ); + assert.equal(channelListWarning('laya', ready, true), 'Local AI service is unavailable. Standard channel matching is being used.'); }); test('installed model with unavailable service is not reported ready', () => { From 4d662369c95a52858d88bd9e5495a19aa44471a2 Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 15:15:06 +0200 Subject: [PATCH 15/36] fix: clear persistent Laya warning and apply language selection immediately (#12) Two fixes: the ChannelList local-AI warning kept showing after the runtime became available because status changes were not re-evaluated on refresh; and selecting a language in the player did not switch the stream variant immediately. Refresh warning visibility from the latest laya status and apply the chosen variant/language on selection, with tests for both behaviors. Co-Authored-By: Claude Opus 4.8 --- public/index.html | 8 ++++---- public/js/components/ChannelList.js | 13 +++++++++++-- public/js/pages/Settings.js | 5 +++++ public/js/services/channelVariantGrouping.js | 19 +++++++++++++++++++ public/js/services/layaStatusPresentation.js | 5 ++++- test/channelVariantGrouping.test.js | 19 +++++++++++++++++++ test/layaStatusPresentation.test.js | 2 +- 7 files changed, 63 insertions(+), 8 deletions(-) diff --git a/public/index.html b/public/index.html index bb4c2d2..287b703 100644 --- a/public/index.html +++ b/public/index.html @@ -1184,9 +1184,9 @@ - - - + + + @@ -1194,7 +1194,7 @@ - + - + - - + + + diff --git a/public/js/api.js b/public/js/api.js index 03449c4..5f3a990 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -77,6 +77,15 @@ const API = { hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType }) }, + live: { + getChannels: ({ sourceId = null, includeHidden = false } = {}) => { + const params = new URLSearchParams(); + if (sourceId) params.set('sourceId', sourceId); + params.set('includeHidden', String(Boolean(includeHidden))); + return API.request('GET', `/live/channels?${params.toString()}`); + } + }, + // Favorites favorites: { getAll: (sourceId = null, itemType = null) => { diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 788fcd7..6e90171 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -35,6 +35,7 @@ class ChannelList { this.languageDetectionNotice = ''; this.languageDetectionFallback = false; this._languageDetectionRequest = 0; + this.projectionNotice = ''; this.loadCollapsedState(); this.init(); @@ -436,6 +437,13 @@ class ChannelList { this.currentBatch = 0; this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups) this.container.innerHTML = ''; // Clear container + if (this.projectionNotice) { + const notice = document.createElement('div'); + notice.className = 'live-projection-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = this.projectionNotice; + this.container.appendChild(notice); + } if (this.languageDetectionNotice) { const notice = document.createElement('div'); notice.className = 'laya-language-notice'; @@ -913,6 +921,12 @@ class ChannelList { try { this.container.innerHTML = '
'; + if (await this.loadProjectedChannels(parseInt(id, 10))) { + this.render(); + void this.refreshLayaLanguages(); + return; + } + if (type === 'xtream') { await this.loadXtreamChannels(parseInt(id)); } else if (type === 'm3u') { @@ -945,6 +959,12 @@ class ChannelList { try { this.container.innerHTML = '
'; + if (await this.loadProjectedChannels()) { + this.render(); + void this.refreshLayaLanguages(); + return; + } + const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled); const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled); console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length); @@ -968,6 +988,26 @@ class ChannelList { } } + async loadProjectedChannels(sourceId = null) { + try { + const response = await API.live.getChannels({ sourceId, includeHidden: true }); + if (!LiveChannelProjection.canUse(response)) { + this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.'; + return false; + } + const mapped = LiveChannelProjection.mapResponse(response, this.sources); + this.channels = mapped.channels; + this.visibleFavorites = mapped.favorites; + this.hiddenItems = mapped.hiddenItems; + this.projectionNotice = ''; + return true; + } catch (err) { + console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message); + this.projectionNotice = ''; + return false; + } + } + /** * Load Xtream channels */ diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js index f7220a8..3372d73 100644 --- a/public/js/services/channelVariantGrouping.js +++ b/public/js/services/channelVariantGrouping.js @@ -93,7 +93,7 @@ const buckets = new Map(); channels.forEach(channel => { - const fingerprint = normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`; + const fingerprint = channel.logicalFingerprint || normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`; if (!buckets.has(fingerprint)) buckets.set(fingerprint, []); buckets.get(fingerprint).push(channel); }); @@ -139,10 +139,10 @@ ])); return { ...preferredVariant, - id: `logical:${fingerprint}`, + id: preferredVariant.logicalId || `logical:${fingerprint}`, fingerprint, - displayName: displayNameForFingerprint(fingerprint), - name: displayNameForFingerprint(fingerprint), + displayName: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint), + name: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint), variants, visibleVariants, preferredVariant, diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js new file mode 100644 index 0000000..f2a1e9f --- /dev/null +++ b/public/js/services/liveChannelProjection.js @@ -0,0 +1,50 @@ +(function (root, factory) { + const service = factory(); + if (typeof module === 'object' && module.exports) module.exports = service; + if (root) root.LiveChannelProjection = service; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + function mapResponse(response, sources = []) { + if (!response || !Array.isArray(response.channels)) { + throw new Error('Invalid live channel projection response'); + } + + const sourceById = new Map(sources.map(source => [String(source.id), source])); + const favorites = new Set(); + const hiddenItems = new Set(); + const channels = []; + + response.channels.forEach(logicalChannel => { + if (!logicalChannel || !Array.isArray(logicalChannel.variants)) return; + logicalChannel.variants.forEach(variant => { + const source = sourceById.get(String(variant.sourceId)); + const id = String(variant.id); + const mapped = { + ...variant, + id, + logicalId: logicalChannel.id, + logicalFingerprint: logicalChannel.fingerprint || logicalChannel.id, + logicalDisplayName: logicalChannel.displayName || logicalChannel.name, + streamId: variant.streamId ?? id, + tvgId: variant.tvgId ?? variant.epg_channel_id ?? null, + tvgLogo: variant.tvgLogo ?? variant.streamIcon ?? variant.stream_icon ?? '', + url: variant.url ?? variant.streamUrl ?? variant.stream_url ?? '', + groupTitle: variant.groupTitle ?? variant.categoryName ?? variant.categoryId ?? 'Uncategorized', + sourceType: variant.sourceType ?? source?.type ?? 'xtream', + sourceName: variant.sourceName ?? source?.name ?? '' + }; + channels.push(mapped); + const key = `${mapped.sourceId}:${id}`; + if (variant.isFavorite) favorites.add(key); + if (variant.isHidden) hiddenItems.add(`channel:${mapped.sourceId}:${mapped.streamId || id}`); + }); + }); + + return { channels, favorites, hiddenItems, projection: response.projection || {} }; + } + + function canUse(response) { + return Boolean(response && Array.isArray(response.channels) && !response.projection?.stale); + } + + return { canUse, mapResponse }; +}); diff --git a/server/db/liveProjectionSchema.js b/server/db/liveProjectionSchema.js new file mode 100644 index 0000000..7be2e5e --- /dev/null +++ b/server/db/liveProjectionSchema.js @@ -0,0 +1,49 @@ +const LIVE_PROJECTION_COLUMNS = { + logical_channel_id: 'TEXT', + grouping_fingerprint: 'TEXT', + grouping_input_hash: 'TEXT', + quality_label: 'TEXT', + quality_rank: 'INTEGER', + variant_rank: 'INTEGER', + language_code: 'TEXT', + language_confidence: 'REAL', + language_scores: 'JSON', + language_evidence: 'JSON', + language_detector: 'TEXT', + language_input_hash: 'TEXT' +}; + +const LIVE_PROJECTION_SCHEMA_SQL = ` + CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id); + CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint); + + CREATE TABLE IF NOT EXISTS logical_channels ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + preferred_variant_id TEXT, + variant_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_logical_channels_fingerprint ON logical_channels(fingerprint); + + CREATE TABLE IF NOT EXISTS catalog_projection_state ( + projection TEXT PRIMARY KEY, + status TEXT NOT NULL, + started_at INTEGER, + completed_at INTEGER, + input_hash TEXT, + projected_count INTEGER NOT NULL DEFAULT 0, + error TEXT + ); +`; + +function ensureColumns(database, table, columns) { + const existing = new Set(database.prepare(`PRAGMA table_info(${table})`).all().map(column => column.name)); + for (const [name, definition] of Object.entries(columns)) { + if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } +} + +module.exports = { ensureColumns, LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL }; diff --git a/server/db/sqlite.js b/server/db/sqlite.js index e73e4f0..be6cc63 100644 --- a/server/db/sqlite.js +++ b/server/db/sqlite.js @@ -1,6 +1,11 @@ const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); +const { + ensureColumns, + LIVE_PROJECTION_COLUMNS, + LIVE_PROJECTION_SCHEMA_SQL +} = require('./liveProjectionSchema'); const dataDir = path.join(__dirname, '..', '..', 'data'); const dbPath = path.join(dataDir, 'content.db'); @@ -73,6 +78,9 @@ function initSchema() { CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id); `); + ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); + db.exec(LIVE_PROJECTION_SCHEMA_SQL); + // EPG Programs // Optimized for range queries db.exec(` @@ -247,6 +255,8 @@ const layaLanguageCache = { module.exports = { getDb, initSchema, + ensureColumns, + LIVE_PROJECTION_COLUMNS, favorites, layaLanguageCache }; diff --git a/server/index.js b/server/index.js index 4dd1fa1..c580887 100644 --- a/server/index.js +++ b/server/index.js @@ -181,6 +181,7 @@ app.use('/api/subtitle', require('./routes/subtitle')); app.use('/api/settings', require('./routes/settings')); app.use('/api/history', require('./routes/history')); app.use('/api/laya', require('./routes/laya')); +app.use('/api/live', require('./routes/live')); // Version endpoint app.get('/api/version', (req, res) => { diff --git a/server/routes/live.js b/server/routes/live.js new file mode 100644 index 0000000..3c6848c --- /dev/null +++ b/server/routes/live.js @@ -0,0 +1,46 @@ +const express = require('express'); +const { requireAuth } = require('../auth'); +const liveProjection = require('../services/liveProjectionService'); + +const router = express.Router(); + +function serializeState(state) { + return { + status: state.status, + stale: state.status !== 'success', + startedAt: state.started_at, + completedAt: state.completed_at, + inputHash: state.input_hash, + projectedCount: state.projected_count, + error: state.error + }; +} + +router.get('/channels', requireAuth, async (req, res) => { + const sourceId = req.query.sourceId === undefined ? null : Number(req.query.sourceId); + if (sourceId !== null && (!Number.isInteger(sourceId) || sourceId <= 0)) { + return res.status(400).json({ error: 'sourceId must be a positive integer' }); + } + if (req.query.includeHidden !== undefined && !['true', 'false'].includes(req.query.includeHidden)) { + return res.status(400).json({ error: 'includeHidden must be true or false' }); + } + const includeHidden = req.query.includeHidden === 'true'; + + try { + let state = liveProjection.projectionState(); + if (state.status !== 'success') state = liveProjection.rebuild(); + const channels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id }); + return res.json({ projection: serializeState(state), channels }); + } catch (error) { + console.error('[LiveProjection] Failed to serve projected channels:', error.message); + const state = liveProjection.projectionState(); + return res.status(503).json({ + projection: serializeState(state), + channels: [], + error: 'Live channel projection is unavailable' + }); + } +}); + +module.exports = router; +module.exports.serializeState = serializeState; diff --git a/server/routes/sources.js b/server/routes/sources.js index 3184fc2..c27cdd0 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -5,6 +5,7 @@ const { getDb } = require('../db/sqlite'); const xtreamApi = require('../services/xtreamApi'); const syncService = require('../services/syncService'); const m3uParser = require('../services/m3uParser'); +const liveProjection = require('../services/liveProjectionService'); // Get all sources router.get('/', async (req, res) => { @@ -132,6 +133,7 @@ router.delete('/:id', async (req, res) => { // Delete source config and related hidden items (favorites handled by db.js) await sources.delete(sourceId); + await liveProjection.queueRebuild(); res.json({ success: true }); } catch (err) { @@ -278,4 +280,3 @@ router.post('/sync-all', async (req, res) => { }); module.exports = router; - diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js new file mode 100644 index 0000000..1b0d270 --- /dev/null +++ b/server/services/liveProjectionService.js @@ -0,0 +1,251 @@ +const crypto = require('crypto'); + +const QUALITY_TOKENS = new Set([ + '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', + '1080p', '1080i', '720p', '576p', '480p' +]); +const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i; + +function words(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(REGION_PREFIX, '') + .replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ') + .split(/\s+/) + .filter(Boolean); +} + +function normalizeChannelName(name) { + return words(name).filter(token => !QUALITY_TOKENS.has(token)).join(' ').trim(); +} + +function displayNameForFingerprint(fingerprint) { + return fingerprint.split(' ').map(token => { + if (token === 'bein') return 'beIN'; + if (/^\d+$/.test(token)) return token; + return token.charAt(0).toUpperCase() + token.slice(1); + }).join(' '); +} + +function qualityForName(name) { + const tokens = words(name); + if (tokens.includes('4k') || tokens.includes('uhd')) return { label: '4K', rank: 4 }; + if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) { + return { label: 'Full HD', rank: 3 }; + } + if (tokens.includes('hd') || tokens.includes('720p')) return { label: 'HD', rank: 2 }; + if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return { label: 'SD', rank: 0 }; + return { label: 'Standard', rank: 1 }; +} + +function stableHash(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function projectCatalog(items) { + const buckets = new Map(); + for (const item of items) { + const fallback = `channel-${item.source_id}:${item.item_id}`; + const fingerprint = normalizeChannelName(item.name) || fallback; + const quality = qualityForName(item.name); + const projected = { + ...item, + fingerprint, + groupingInputHash: stableHash(`${item.name || ''}\0${item.category_id || ''}`), + qualityLabel: quality.label, + qualityRank: quality.rank + }; + if (!buckets.has(fingerprint)) buckets.set(fingerprint, []); + buckets.get(fingerprint).push(projected); + } + + return Array.from(buckets, ([fingerprint, variants]) => { + variants.sort((a, b) => b.qualityRank - a.qualityRank || a.source_id - b.source_id || String(a.item_id).localeCompare(String(b.item_id))); + variants.forEach((variant, index) => { variant.variantRank = index; }); + return { + id: `logical:${stableHash(fingerprint).slice(0, 24)}`, + fingerprint, + displayName: displayNameForFingerprint(fingerprint), + preferredVariantId: variants[0].id, + variants + }; + }).sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +function catalogInputHash(items) { + return stableHash(items + .map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`) + .sort() + .join('\n')); +} + +function defaultDatabase() { + return require('../db/sqlite').getDb(); +} + +function projectionState(database = null) { + database ||= defaultDatabase(); + return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || { + projection: 'live', status: 'missing', started_at: null, completed_at: null, + input_hash: null, projected_count: 0, error: null + }; +} + +function rebuild(database = null) { + database ||= defaultDatabase(); + const items = database.prepare(` + SELECT id, source_id, item_id, name, category_id + FROM playlist_items WHERE type = 'live' + `).all(); + const groups = projectCatalog(items); + const inputHash = catalogInputHash(items); + const startedAt = Date.now(); + database.prepare(` + INSERT INTO catalog_projection_state (projection, status, started_at, completed_at, input_hash, projected_count, error) + VALUES ('live', 'building', ?, NULL, NULL, 0, NULL) + ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at, + completed_at = NULL, error = NULL + `).run(startedAt); + + try { + const apply = database.transaction(() => { + database.prepare('DELETE FROM logical_channels').run(); + database.prepare(` + UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL, + grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL + WHERE type = 'live' + `).run(); + const insertLogical = database.prepare(` + INSERT INTO logical_channels + (id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const updateVariant = database.prepare(` + UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?, + grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ? + WHERE id = ? AND type = 'live' + `); + const now = Date.now(); + for (const group of groups) { + insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now); + for (const variant of group.variants) { + updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash, + variant.qualityLabel, variant.qualityRank, variant.variantRank, variant.id); + } + } + database.prepare(` + UPDATE catalog_projection_state SET status = 'success', completed_at = ?, + input_hash = ?, projected_count = ?, error = NULL WHERE projection = 'live' + `).run(Date.now(), inputHash, groups.length); + }); + apply(); + return projectionState(database); + } catch (error) { + database.prepare(` + UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ? + WHERE projection = 'live' + `).run(Date.now(), 'Live catalog projection failed'); + throw error; + } +} + +let rebuildQueue = Promise.resolve(); +function queueRebuild() { + const next = rebuildQueue.catch(() => {}).then(() => rebuild()); + rebuildQueue = next; + return next; +} + +function parseData(value) { + try { return value ? JSON.parse(value) : {}; } catch { return {}; } +} + +function getChannels({ sourceId = null, includeHidden = false, userId = null }, database = null) { + database ||= defaultDatabase(); + const params = []; + let sourceFilter = ''; + if (sourceId !== null) { + sourceFilter = 'AND p.source_id = ?'; + params.push(sourceId); + } + const rows = database.prepare(` + SELECT p.*, c.name AS category_name, COALESCE(c.is_hidden, 0) AS category_hidden, + CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite, + l.display_name, l.preferred_variant_id + 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' ${sourceFilter} + ORDER BY l.display_name, p.variant_rank + `).all(userId ?? -1, ...params); + return serializeChannels(rows, includeHidden); +} + +function serializeChannels(rows, includeHidden = false) { + const groups = new Map(); + for (const row of rows) { + const hidden = Boolean(row.is_hidden || row.category_hidden); + if (hidden && !includeHidden) continue; + const raw = parseData(row.data); + const categoryName = raw.groupTitle || row.category_name || 'Uncategorized'; + const variant = { + ...raw, + id: row.item_id, + sourceId: row.source_id, + name: row.name, + categoryId: row.category_id, + categoryName, + groupTitle: categoryName, + streamIcon: row.stream_icon, + streamUrl: row.stream_url, + qualityLabel: row.quality_label, + qualityRank: row.quality_rank, + variantRank: row.variant_rank, + isHidden: hidden, + isFavorite: Boolean(row.user_favorite) + }; + if (!groups.has(row.logical_channel_id)) { + groups.set(row.logical_channel_id, { + id: row.logical_channel_id, + fingerprint: row.grouping_fingerprint, + displayName: row.display_name, + name: row.display_name, + variants: [], + isFavorite: false, + isHidden: true + }); + } + const group = groups.get(row.logical_channel_id); + group.variants.push(variant); + group.isFavorite ||= variant.isFavorite; + group.isHidden &&= variant.isHidden; + if (row.id === row.preferred_variant_id) group.preferredVariant = variant; + } + return Array.from(groups.values()).map(group => { + group.preferredVariant ||= group.variants[0]; + group.categoryId = group.preferredVariant?.categoryId || null; + group.categoryName = group.preferredVariant?.categoryName || 'Uncategorized'; + group.groupTitle = group.preferredVariant?.groupTitle || group.categoryName; + group.selectedVariantId = group.preferredVariant + ? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}` + : null; + return group; + }); +} + +module.exports = { + catalogInputHash, + displayNameForFingerprint, + getChannels, + normalizeChannelName, + projectCatalog, + projectionState, + qualityForName, + queueRebuild, + rebuild, + serializeChannels +}; diff --git a/server/services/syncService.js b/server/services/syncService.js index 8ac35f5..94c36f3 100644 --- a/server/services/syncService.js +++ b/server/services/syncService.js @@ -3,6 +3,7 @@ const { sources, settings } = require('../db'); // For source config and setting const xtreamApi = require('./xtreamApi'); const m3uParser = require('./m3uParser'); const epgParser = require('./epgParser'); +const liveProjection = require('./liveProjectionService'); // Sync tracking const activeSyncs = new Set(); // sourceId @@ -183,6 +184,7 @@ class SyncService { console.log(`[Sync] Fetching Live Streams for ${source.name}`); const liveStreams = await api.getLiveStreams(); await this.saveStreams(source.id, 'live', liveStreams); + await liveProjection.queueRebuild(); // 3. VOD Categories console.log(`[Sync] Fetching VOD Categories for ${source.name}`); @@ -559,6 +561,7 @@ class SyncService { })); await this.saveCategories(source.id, 'live', categories); + await liveProjection.queueRebuild(); console.log(`[Sync] M3U sync complete for ${source.name}`); } diff --git a/test/channelVariantGrouping.test.js b/test/channelVariantGrouping.test.js index 925ab60..1ef82c8 100644 --- a/test/channelVariantGrouping.test.js +++ b/test/channelVariantGrouping.test.js @@ -21,6 +21,17 @@ test('does not merge distinct numbered channels', () => { assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']); }); +test('preserves precomputed projection boundaries and display names', () => { + const logical = grouping.groupChannels([ + channel('1', 'Shared Name HD', { logicalId: 'logical:a', logicalFingerprint: 'a', logicalDisplayName: 'Backend A' }), + channel('2', 'Shared Name 4K', { logicalId: 'logical:b', logicalFingerprint: 'b', logicalDisplayName: 'Backend B' }) + ]); + assert.deepEqual(logical.map(item => [item.id, item.displayName]), [ + ['logical:a', 'Backend A'], + ['logical:b', 'Backend B'] + ]); +}); + test('logical favorite and hidden state are derived from individual streams', () => { const variants = [channel('1', 'BEIN SPORTS 1 HD'), channel('2', 'BEIN SPORTS 1 4K')]; const logical = grouping.groupChannels(variants, { diff --git a/test/liveChannelProjection.test.js b/test/liveChannelProjection.test.js new file mode 100644 index 0000000..9141d8f --- /dev/null +++ b/test/liveChannelProjection.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const projection = require('../public/js/services/liveChannelProjection.js'); + +test('maps projected logical channels to the frontend variant format', () => { + const response = { + projection: { status: 'success', stale: false }, + channels: [{ + id: 'logical:one', fingerprint: 'news', displayName: 'News', + variants: [{ + id: 42, sourceId: 7, name: 'News HD', categoryId: 'News', + streamIcon: 'logo.png', streamUrl: 'https://stream.test/live', + tvgId: 'news.fr', isFavorite: true, isHidden: true + }] + }] + }; + const mapped = projection.mapResponse(response, [{ id: 7, type: 'm3u', name: 'Local' }]); + assert.deepEqual(mapped.channels[0], { + id: '42', sourceId: 7, name: 'News HD', categoryId: 'News', + streamIcon: 'logo.png', streamUrl: 'https://stream.test/live', tvgId: 'news.fr', + isFavorite: true, isHidden: true, logicalId: 'logical:one', + logicalFingerprint: 'news', logicalDisplayName: 'News', + streamId: '42', tvgLogo: 'logo.png', + url: 'https://stream.test/live', groupTitle: 'News', sourceType: 'm3u', sourceName: 'Local' + }); + assert.equal(mapped.favorites.has('7:42'), true); + assert.equal(mapped.hiddenItems.has('channel:7:42'), true); +}); + +test('rejects stale projections so ChannelList can use its legacy fallback', () => { + assert.equal(projection.canUse({ projection: { stale: true }, channels: [] }), false); + assert.equal(projection.canUse({ projection: { stale: false }, channels: [] }), true); + assert.equal(projection.canUse(null), false); +}); diff --git a/test/liveProjectionService.test.js b/test/liveProjectionService.test.js new file mode 100644 index 0000000..8293ba1 --- /dev/null +++ b/test/liveProjectionService.test.js @@ -0,0 +1,102 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + projectCatalog, + serializeChannels +} = require('../server/services/liveProjectionService'); +const { + LIVE_PROJECTION_COLUMNS, + LIVE_PROJECTION_SCHEMA_SQL +} = require('../server/db/liveProjectionSchema'); + +const item = (id, name, extra = {}) => ({ + id: `1:${id}`, + source_id: 1, + item_id: String(id), + name, + category_id: 'sports', + ...extra +}); + +test('projection migration declares grouping and language columns', () => { + assert.deepEqual(Object.keys(LIVE_PROJECTION_COLUMNS), [ + 'logical_channel_id', 'grouping_fingerprint', 'grouping_input_hash', + 'quality_label', 'quality_rank', 'variant_rank', 'language_code', + 'language_confidence', 'language_scores', 'language_evidence', + 'language_detector', 'language_input_hash' + ]); + assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS logical_channels/); + assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS catalog_projection_state/); + assert.match(LIVE_PROJECTION_SCHEMA_SQL, /idx_items_logical_channel/); +}); + +test('projects beIN quality variants into one logical channel', () => { + const groups = projectCatalog([ + item('uhd', '|FR| BEIN SPORTS 1 UHD'), + item('4k', '|FR| BEIN SPORTS 1 4K'), + item('car', 'FR-CAR| bein sports 1') + ]); + assert.equal(groups.length, 1); + assert.equal(groups[0].fingerprint, 'bein sports 1'); + assert.equal(groups[0].displayName, 'beIN Sports 1'); + assert.equal(groups[0].variants.length, 3); + assert.deepEqual(groups[0].variants.map(variant => variant.variantRank), [0, 1, 2]); +}); + +test('does not merge distinct numbered beIN channels', () => { + const groups = projectCatalog([ + item('1', 'beIN Sports 1 HD'), + item('2', 'beIN Sports 2 HD') + ]); + assert.deepEqual(groups.map(group => group.fingerprint), ['bein sports 1', 'bein sports 2']); +}); + +test('renaming a stream changes its projection membership and input hash', () => { + const before = projectCatalog([item('1', 'beIN Sports 1 HD'), item('2', 'beIN Sports 1 4K')]); + const after = projectCatalog([item('1', 'beIN Sports 2 HD'), item('2', 'beIN Sports 1 4K')]); + assert.equal(before.length, 1); + assert.equal(after.length, 2); + const beforeRename = before.flatMap(group => group.variants).find(variant => variant.id === '1:1'); + const afterRename = after.flatMap(group => group.variants).find(variant => variant.id === '1:1'); + assert.notEqual(beforeRename.groupingInputHash, afterRename.groupingInputHash); +}); + +test('API serialization filters source rows upstream and hidden variants by option', () => { + const rows = [ + { + ...item('1', 'beIN Sports 1 HD'), logical_channel_id: 'logical:1', + grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1', + preferred_variant_id: '1:1', quality_label: 'HD', quality_rank: 2, + variant_rank: 0, is_hidden: 0, category_hidden: 0, category_name: 'Sports Premium', + user_favorite: 1, data: '{}' + }, + { + ...item('2', 'beIN Sports 1 4K'), logical_channel_id: 'logical:1', + grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1', + preferred_variant_id: '1:1', quality_label: '4K', quality_rank: 4, + variant_rank: 1, is_hidden: 1, category_hidden: 0, category_name: 'Sports Premium', + user_favorite: 0, data: '{}' + } + ]; + const visible = serializeChannels(rows, false); + assert.equal(visible[0].variants.length, 1); + assert.equal(visible[0].isFavorite, true); + assert.equal(visible[0].groupTitle, 'Sports Premium'); + assert.equal(visible[0].categoryName, 'Sports Premium'); + assert.equal(visible[0].variants[0].groupTitle, 'Sports Premium'); + assert.equal(serializeChannels(rows, true)[0].variants.length, 2); +}); + +test('API serialization preserves an M3U groupTitle over the stored category fallback', () => { + const rows = [{ + ...item('m3u', 'M3U Sports HD'), logical_channel_id: 'logical:m3u', + grouping_fingerprint: 'm3u sports', display_name: 'M3U Sports', + preferred_variant_id: '1:m3u', quality_label: 'HD', quality_rank: 2, + variant_rank: 0, is_hidden: 0, category_hidden: 0, + category_name: 'Fallback category', user_favorite: 0, + data: JSON.stringify({ groupTitle: 'M3U Sports France' }) + }]; + const [channel] = serializeChannels(rows, false); + assert.equal(channel.groupTitle, 'M3U Sports France'); + assert.equal(channel.variants[0].categoryName, 'M3U Sports France'); +}); From 67d9869364b4c7a4e1a95f345aff7819535e4212 Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 16:09:13 +0200 Subject: [PATCH 17/36] feat: add Settings live stream analysis button and diagnose Laya batch failure (#14) Add a Settings action to manually rerun Live TV stream analysis: it triggers an admin-guarded projection rebuild endpoint that groups streams into logical channels (covering channels present before the projection existed) and reruns language/Laya detection, with progress presented via liveAnalysisPresentation. Harden the Laya batch path that failed with 'Local AI language matching failed' by surfacing the underlying cause and falling back to standard matching cleanly. Guard admin-only rebuild through a new authz helper and cover routes, service and presentation with tests. Co-Authored-By: Claude Opus 4.8 --- public/css/main.css | 24 ++++ public/index.html | 25 +++- public/js/api.js | 4 +- public/js/pages/Settings.js | 107 ++++++++++++++++++ .../js/services/liveAnalysisPresentation.js | 48 ++++++++ server/auth.js | 3 +- server/authz.js | 5 + server/db/liveProjectionSchema.js | 14 ++- server/db/sqlite.js | 2 + server/routes/live.js | 32 +++++- server/services/liveProjectionService.js | 65 ++++++++--- test/liveAnalysisPresentation.test.js | 39 +++++++ test/liveProjectionRoutes.test.js | 21 ++++ test/liveProjectionService.test.js | 22 +++- 14 files changed, 386 insertions(+), 25 deletions(-) create mode 100644 public/js/services/liveAnalysisPresentation.js create mode 100644 server/authz.js create mode 100644 test/liveAnalysisPresentation.test.js create mode 100644 test/liveProjectionRoutes.test.js diff --git a/public/css/main.css b/public/css/main.css index c2cbe27..932f0ce 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -768,6 +768,30 @@ h4 { background: var(--color-bg-tertiary); } +.live-analysis-panel { + margin-bottom: var(--space-md); + padding: var(--space-md); + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); +} + +.live-analysis-panel [hidden] { + display: none !important; +} + +.live-analysis-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); +} + +@media (max-width: 600px) { + .live-analysis-heading { + flex-direction: column; + } +} + .laya-model-panel [hidden] { display: none !important; } diff --git a/public/index.html b/public/index.html index 0aaee98..274d708 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,7 @@ nodecast-tv | IPTV Player - +

Channel matching

Choose how Nodecast TV identifies matching streams and languages.

+
+
+
+ Channel analysis +
Group matching streams into channels and update detected languages.
+
+ +
+

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

+
Checking status…
+ +
+ + +