From cb01a590518fe996dc64997160159614235e7c4c Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Fri, 2 Jan 2026 19:37:08 +0100 Subject: [PATCH] Display current song --- src/main.js | 261 ++++++++++++++++++++++++++++++++++----- tools/check_counts.js | 11 ++ tools/find_unclosed.cjs | 24 ++++ tools/find_unclosed.js | 24 ++++ tools/find_unmatched.cjs | 24 ++++ 5 files changed, 312 insertions(+), 32 deletions(-) create mode 100644 tools/check_counts.js create mode 100644 tools/find_unclosed.cjs create mode 100644 tools/find_unclosed.js create mode 100644 tools/find_unmatched.cjs diff --git a/src/main.js b/src/main.js index 4ec4632..a37a4e7 100644 --- a/src/main.js +++ b/src/main.js @@ -31,6 +31,19 @@ const deviceListEl = document.getElementById('device-list'); const logoTextEl = document.querySelector('.station-logo-text'); const logoImgEl = document.getElementById('station-logo-img'); const artworkPlaceholder = document.querySelector('.artwork-placeholder'); +// Global error handlers to avoid silent white screen and show errors in UI +window.addEventListener('error', (ev) => { + try { + console.error('Uncaught error', ev.error || ev.message || ev); + if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.error && ev.error.message ? ev.error.message : ev.message || 'Unknown'); + } catch (e) { /* ignore */ } +}); +window.addEventListener('unhandledrejection', (ev) => { + try { + console.error('Unhandled rejection', ev.reason); + if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.reason && ev.reason.message ? ev.reason.message : String(ev.reason)); + } catch (e) { /* ignore */ } +}); // Editor elements const editBtn = document.getElementById('edit-stations-btn'); const editorOverlay = document.getElementById('editor-overlay'); @@ -46,10 +59,15 @@ const usIndex = document.getElementById('us_index'); // Init async function init() { - restoreSavedVolume(); - await loadStations(); - setupEventListeners(); - updateUI(); + try { + restoreSavedVolume(); + await loadStations(); + setupEventListeners(); + updateUI(); + } catch (e) { + console.error('Error during init', e); + if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e)); + } } // Volume persistence @@ -153,59 +171,236 @@ async function loadStations() { statusTextEl.textContent = 'Error loading stations'; } } + // --- Current Song Polling --- -const currentSongPollers = new Map(); // stationId -> intervalId +const currentSongPollers = new Map(); // stationId -> { intervalId, timeoutId } + +// Attempt to discover the radio.svet24 asset query token used by lastsongs URLs. +// This fetches a stable asset JS (as used on the site) and extracts the value +// between `?` and `")` using a JS-friendly regex. It caches the token on the +// `station._assetToken` property so pollers can append it to lastsongs requests. +async function getAssetTokenForStation(station) { + try { + if (!station) return null; + if (station._assetToken) return station._assetToken; + + // Primary known asset location used on the site + const assetUrl = 'https://radio.svet24.si/assets/index-IMUbJO1D.js'; + + let body = null; + try { + // Use backend proxy to avoid CORS + body = await invoke('fetch_url', { url: assetUrl }); + } catch (e) { + // If fetching the known asset fails, try the station's homepage as a fallback + try { + const home = station.raw && (station.raw.www || station.raw.homepage || station.raw.url); + if (home) body = await invoke('fetch_url', { url: String(home) }); + } catch (e2) { + // swallow fallback errors + } + } + + if (!body) return null; + + // Extract the token: grep -oP '\?\K[^"]+(?="\))' equivalence in JS + // We'll capture the group after the question mark and before the '"))' sequence. + const m = body.match(/\?([^"\)]+)(?="\))/); + if (m && m[1]) { + const token = m[1].replace(/^\?/, ''); + station._assetToken = token; + console.debug('getAssetTokenForStation: found token for', station.id || station.name, token); + return token; + } + + console.debug('getAssetTokenForStation: token not found in fetched asset for', station.id || station.name); + return null; + } catch (e) { + console.debug('getAssetTokenForStation failed', e && e.message ? e.message : e); + return null; + } +} function stopCurrentSongPollers() { - for (const id of currentSongPollers.values()) { - clearInterval(id); + for (const entry of currentSongPollers.values()) { + try { if (entry && entry.intervalId) clearInterval(entry.intervalId); } catch (e) {} + try { if (entry && entry.timeoutId) clearTimeout(entry.timeoutId); } catch (e) {} } currentSongPollers.clear(); } function startCurrentSongPollers() { - // Clear existing + // Clear existing (we only run poller for the currently selected station) stopCurrentSongPollers(); - stations.forEach((s, idx) => { - const url = s.raw && s.raw.currentSong; - if (url && typeof url === 'string' && url.length > 0) { - // fetch immediately and then every 10s - fetchAndStoreCurrentSong(s, idx, url); - const iid = setInterval(() => fetchAndStoreCurrentSong(s, idx, url), 10000); - currentSongPollers.set(s.id || idx, iid); - } - }); + const idx = currentIndex; + const s = stations[idx]; + if (!s) return; + + // Prefer explicit `currentSong` endpoint, fall back to `lastSongs` endpoint + let url = s.raw && (s.raw.currentSong || s.raw.lastSongs); + + if (url && typeof url === 'string' && url.length > 0) { + const baseUrl = String(url); + + // Create a wrapper that computes the effective fetch URL each time so that + // any token discovered asynchronously on the station object is applied + // without restarting the poller. + const doFetch = () => { + try { + let fetchUrl = baseUrl; + if (/lastsongsxml/i.test(fetchUrl)) { + const token = s._assetToken || null; + if (token) { + fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + token; + } else { + // temporary cache-busting param while token discovery runs + fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + Date.now(); + // start background retrieval of token for future requests + getAssetTokenForStation(s).catch(() => {}); + } + } + fetchAndStoreCurrentSong(s, idx, fetchUrl); + } catch (e) { + console.debug('doFetch wrapper error for station', s.id || idx, e); + } + }; + + // fetch immediately and then every 10s + doFetch(); + const iid = setInterval(doFetch, 10000); + // save baseUrl for potential restart and track interval/timeout + s._lastSongsBaseUrl = baseUrl; + currentSongPollers.set(s.id || idx, { intervalId: iid, timeoutId: null }); + } } async function fetchAndStoreCurrentSong(station, idx, url) { try { let data = null; - try { - const resp = await fetch(url, { cache: 'no-store' }); - const ct = resp.headers.get('content-type') || ''; - if (ct.includes('application/json')) { - data = await resp.json(); + // If the URL is remote (http/https), use the backend `fetch_url` to bypass CORS. + let rawBody = null; + if (/^https?:\/\//i.test(url)) { + rawBody = await invoke('fetch_url', { url }); } else { - const txt = await resp.text(); - try { data = JSON.parse(txt); } catch (e) { data = null; } + const resp = await fetch(url, { cache: 'no-store' }); + rawBody = await resp.text(); } - } catch (fetchErr) { - // Possibly blocked by CORS — fall back to backend fetch via Tauri invoke + + // Debug: show fetched body length (avoid huge dumps) + console.debug('fetchAndStoreCurrentSong fetched', url, 'len=', rawBody ? String(rawBody.length) : 'null'); + + // Try to parse JSON. Some endpoints double-encode JSON as a string (i.e. + // the response is a JSON string containing escaped JSON). Handle both + // cases: direct object, or string that needs a second parse. try { - const body = await invoke('fetch_url', { url }); - try { data = JSON.parse(body); } catch (e) { data = null; } - } catch (invokeErr) { - console.debug('Both fetch and backend fetch failed for', url, fetchErr, invokeErr); + if (!rawBody) { + data = null; + } else { + const first = JSON.parse(rawBody); + if (typeof first === 'string') { + // e.g. "{\"title\":...}" + try { + data = JSON.parse(first); + } catch (e2) { + // If second parse fails, attempt to unescape common backslashes and reparse + const unescaped = first.replace(/\\"/g, '"').replace(/\\n/g, '').replace(/\\/g, ''); + try { + data = JSON.parse(unescaped); + } catch (e3) { + console.debug('Failed second-stage parse for', url, e3); + data = null; + } + } + } else { + data = first; + } + } + } catch (e) { + console.debug('Failed to parse JSON from', url, e); data = null; } + + // Normalise different shapes to a currentSong object if possible + let now = null; + if (data) { + if (data.currentSong && (data.currentSong.artist || data.currentSong.title)) { + now = { artist: data.currentSong.artist || '', title: data.currentSong.title || '' }; + } else if (Array.isArray(data.lastSongs) && data.lastSongs.length > 0) { + const first = data.lastSongs[0]; + if (first && (first.artist || first.title)) now = { artist: first.artist || '', title: first.title || '' }; + } else if (data.artist || data.title) { + now = { artist: data.artist || '', title: data.title || '' }; + } else if (data.title && !data.artist) { + // Some stations use `title` only + now = { artist: '', title: data.title }; + } } - if (data && (data.artist || data.title)) { - station.currentSongInfo = { artist: data.artist || '', title: data.title || '' }; + if (now) { + station.currentSongInfo = now; // update UI if this is the currently loaded station if (idx === currentIndex) updateNowPlayingUI(); + + // If we have timing info (from the provider), schedule a single-shot + // refresh to align with the next song instead of polling continuously. + try { + const key = station.id || idx; + // prefer the provider currentSong object if available + const providerCS = data && data.currentSong ? data.currentSong : null; + const startStr = providerCS && providerCS.playTimeStartSec ? providerCS.playTimeStartSec : (providerCS && providerCS.playTimeStart ? providerCS.playTimeStart : null); + const lengthStr = providerCS && providerCS.playTimeLengthSec ? providerCS.playTimeLengthSec : (providerCS && providerCS.playTimeLength ? providerCS.playTimeLength : null); + if (startStr && lengthStr) { + // parse start time `HH:MM:SS` (or `H:MM:SS`) and length `M:SS` or `MM:SS` + const nowDate = new Date(); + const parts = startStr.split(':').map(p=>Number(p)); + if (parts.length >= 2) { + const startDate = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate(), parts[0], parts[1], parts[2]||0, 0); + // adjust if start appears to be on previous day (large positive diff) + const deltaStart = startDate.getTime() - nowDate.getTime(); + if (deltaStart > 12*3600*1000) startDate.setDate(startDate.getDate() - 1); + if (deltaStart < -12*3600*1000) startDate.setDate(startDate.getDate() + 1); + + const lenParts = lengthStr.split(':').map(p=>Number(p)); + let lenSec = 0; + if (lenParts.length === 3) lenSec = lenParts[0]*3600 + lenParts[1]*60 + lenParts[2]; + else if (lenParts.length === 2) lenSec = lenParts[0]*60 + lenParts[1]; + else lenSec = Number(lenParts[0]) || 0; + + const endMs = startDate.getTime() + (lenSec * 1000); + const msUntilEnd = endMs - nowDate.getTime(); + if (msUntilEnd > 1000) { + // Clear existing interval and timeouts for this station + const entry = currentSongPollers.get(key); + if (entry && entry.intervalId) { + try { clearInterval(entry.intervalId); } catch (e) {} + } + if (entry && entry.timeoutId) { + try { clearTimeout(entry.timeoutId); } catch (e) {} + } + + // Schedule a one-shot fetch at song end. After firing, restart pollers + const timeoutId = setTimeout(async () => { + try { + // re-fetch using the same effective URL (may include token) + await fetchAndStoreCurrentSong(station, idx, url); + } catch (e) { + console.debug('scheduled fetch failed for', key, e); + } finally { + // If still on this station, restart the regular poller + if (currentIndex === idx) startCurrentSongPollers(); + } + }, msUntilEnd + 250); + + currentSongPollers.set(key, { intervalId: null, timeoutId }); + console.debug('Scheduled next fetch for', key, 'in ms=', msUntilEnd); + } + } + } + } catch (e) { + console.debug('Failed scheduling next-song fetch', e); + } } } catch (e) { // ignore fetch errors silently (network/CORS) but keep console for debugging @@ -455,6 +650,8 @@ function loadStation(index) { } logoTextEl.classList.remove('hidden'); } + // When loading a station, ensure only this station's poller runs + try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); } } // Check if an image URL is reachable and valid diff --git a/tools/check_counts.js b/tools/check_counts.js new file mode 100644 index 0000000..00558e2 --- /dev/null +++ b/tools/check_counts.js @@ -0,0 +1,11 @@ +const fs = require('fs'); +const path = 'd:/Sites/Work/RadioCast/src/main.js'; +const s = fs.readFileSync(path,'utf8'); +const counts = {'(':0,')':0,'{':0,'}','[':0,']':0,'`':0,'"':0,"'":0}; +for (const ch of s) { if (counts.hasOwnProperty(ch)) counts[ch]++; } +console.log('counts:', counts); +// Also print last 50 characters and line count +console.log('length:', s.length); +const lines = s.split(/\r?\n/); +console.log('lines:', lines.length); +for (let i = Math.max(0, lines.length-20); i < lines.length; i++) console.log((i+1)+': '+lines[i]); diff --git a/tools/find_unclosed.cjs b/tools/find_unclosed.cjs new file mode 100644 index 0000000..57d713d --- /dev/null +++ b/tools/find_unclosed.cjs @@ -0,0 +1,24 @@ +const fs = require('fs'); +const path = 'd:/Sites/Work/RadioCast/src/main.js'; +const s = fs.readFileSync(path,'utf8'); +const lines = s.split(/\r?\n/); +let balance = 0; +let maxBalance = 0; +let maxLine = -1; +for (let i=0;imaxBalance){ maxBalance = balance; maxLine = i+1; } +} +console.log('final balance:', balance, 'maxBalance:', maxBalance, 'maxLine:', maxLine); +console.log('last 40 lines:'); +for (let i=Math.max(0, lines.length-40); i0){ + console.log('\nContext around max imbalance at line', maxLine); + for (let i=Math.max(1, maxLine-5); i<=Math.min(lines.length, maxLine+5); i++) console.log(i+': '+lines[i-1]); +} diff --git a/tools/find_unclosed.js b/tools/find_unclosed.js new file mode 100644 index 0000000..57d713d --- /dev/null +++ b/tools/find_unclosed.js @@ -0,0 +1,24 @@ +const fs = require('fs'); +const path = 'd:/Sites/Work/RadioCast/src/main.js'; +const s = fs.readFileSync(path,'utf8'); +const lines = s.split(/\r?\n/); +let balance = 0; +let maxBalance = 0; +let maxLine = -1; +for (let i=0;imaxBalance){ maxBalance = balance; maxLine = i+1; } +} +console.log('final balance:', balance, 'maxBalance:', maxBalance, 'maxLine:', maxLine); +console.log('last 40 lines:'); +for (let i=Math.max(0, lines.length-40); i0){ + console.log('\nContext around max imbalance at line', maxLine); + for (let i=Math.max(1, maxLine-5); i<=Math.min(lines.length, maxLine+5); i++) console.log(i+': '+lines[i-1]); +} diff --git a/tools/find_unmatched.cjs b/tools/find_unmatched.cjs new file mode 100644 index 0000000..b70b030 --- /dev/null +++ b/tools/find_unmatched.cjs @@ -0,0 +1,24 @@ +const fs=require('fs'); +const path='d:/Sites/Work/RadioCast/src/main.js'; +const s=fs.readFileSync(path,'utf8'); +const lines=s.split(/\r?\n/); +const stack=[]; +for(let i=0;i0) stack.pop(); else console.log('Unmatched closing } at',i+1,j+1); + } + } +} +console.log('Unmatched openings left:', stack.length); +if(stack.length>0) console.log('First unmatched opening at', stack[0]); +if(stack.length>0) console.log('Last unmatched opening at', stack[stack.length-1]); +if(stack.length>0){ + const sidx=Math.max(1, stack[stack.length-1].line-5); + const eidx=Math.min(lines.length, stack[stack.length-1].line+5); + console.log('\nContext:'); + for(let i=sidx;i<=eidx;i++) console.log(i+': '+lines[i-1]); +}