1(function () { 2 const cfg = () => window.RQST_CONFIG; 3 const requestSeq = { 1: 0, 2: 0 }; 4 const delayTimer = { 1: null, 2: null }; 5 const temporaryTimer = { 1: null, 2: null }; 6 const temporaryUntil = { 1: 0, 2: 0 }; 7 const lastTrackKey = { 1: '', 2: '' }; 8 const trackSince = { 1: 0, 2: 0 }; 9 const resolvedTrackKey = { 1: '', 2: '' }; 10 const lastAttemptAt = { 1: 0, 2: 0 }; 11 const staleAppliedKey = { 1: '', 2: '' }; 12 const applySeq = { 1: 0, 2: 0 }; 13 const persistentCover = { 14 1: { src: '', sourceText: 'RQST Cover' }, 15 2: { src: '', sourceText: 'RQST Cover' } 16 }; 17 const programCover = { 18 1: { src: '', sourceText: 'RQST Programa' }, 19 2: { src: '', sourceText: 'RQST Programa' } 20 }; 21 let discogsDisabledUntil = 0; 22 23 function clean(value) { 24 return String(value ?? '').replace(/\s+/g, ' ').trim(); 25 } 26 27 function isInvalidValue(value) { 28 const raw = clean(value); 29 const normalized = raw.toLowerCase(); 30 if (!normalized || [ 31 'undefined', 'null', 'ninguno', 'ninguna', 'none', '-', 32 'desconocido', 'desconocida', 'sin tÃtulo', 'sin titulo', 33 'sin información', 'sin informacion', 'sin datos', 'sin dato', 34 'unknown', 'no information', 'not available', 'n/a', 'na' 35 ].includes(normalized)) return true; 36 37 // Tags dañados por conversiones de codificación (mojibake). Se valida cada 38 // campo por separado: si el álbum falla, todavÃa puede usarse artista + tema. 39 if (/�/.test(raw)) return true; 40 if (/(?:Ã.|Ã.|ââ¬|ââ¬â¢|ââ¬Å|ââ¬ï¿½|ðŸ|�)/i.test(raw)) return true; 41 42 // Cadenas dominadas por sÃmbolos o controles suelen ser tags ilegibles. 43 const compact = raw.replace(/\s/g, ''); 44 const readable = (compact.match(/[\p{L}\p{N}]/gu) || []).length; 45 if (compact.length >= 5 && readable / compact.length < 0.45) return true; 46 47 return false; 48 } 49 50 function trackKey(artist, title, album) { 51 return `${clean(artist)}|${clean(title)}|${clean(album)}`.toLowerCase(); 52 } 53 54 function imageElement(signalId) { 55 return document.getElementById(`cover-${signalId}`); 56 } 57 58 function sourceElement(signalId) { 59 return document.getElementById(`cover-source-${signalId}`); 60 } 61 62 function applyCover(signalId, src, sourceText) { 63 const img = imageElement(signalId); 64 const label = sourceElement(signalId); 65 const safeSrc = clean(src) || cfg().invalidProgramCover || cfg().fallbackCover; 66 if (!img) return; 67 68 const seq = ++applySeq[signalId]; 69 const preloader = new Image(); 70 preloader.onload = () => { 71 if (seq !== applySeq[signalId]) return; 72 img.src = safeSrc; 73 if (label) label.textContent = sourceText || 'RQST Cover'; 74 }; 75 preloader.onerror = () => { 76 if (seq !== applySeq[signalId]) return; 77 console.warn('Cover image unavailable:', safeSrc); 78 const fallbackSrc = cfg().invalidProgramCover || cfg().halfHourCover || cfg().fallbackCover; 79 if (safeSrc !== fallbackSrc) { 80 img.src = fallbackSrc; 81 if (label) label.textContent = 'RQST Default'; 82 } 83 }; 84 preloader.src = safeSrc; 85 } 86 87 function isTemporaryActive(signalId) { 88 return Date.now() < (temporaryUntil[signalId] || 0); 89 } 90 91 function setPersistentCover(signalId, src, sourceText) { 92 persistentCover[signalId] = { 93 src: clean(src) || getProgramCover(signalId).src, 94 sourceText: sourceText || 'RQST Cover' 95 }; 96 if (!isTemporaryActive(signalId)) { 97 applyCover(signalId, persistentCover[signalId].src, persistentCover[signalId].sourceText); 98 } 99 } 100 101 function getProgramCover(signalId) { 102 const saved = programCover[signalId]; 103 return { 104 src: clean(saved?.src) || cfg().fallbackCover || cfg().invalidProgramCover, 105 sourceText: saved?.sourceText || 'RQST Programa' 106 }; 107 } 108 109 function setProgramCover(signalId, src, sourceText = 'RQST Programa') { 110 const previous = getProgramCover(signalId); 111 const next = { 112 src: clean(src) || cfg().fallbackCover || cfg().invalidProgramCover, 113 sourceText 114 }; 115 programCover[signalId] = next; 116 117 // Al cargar la programación o cambiar de bloque, la nueva identidad 118 // editorial se muestra de inmediato. Las recargas sin cambios no pisan 119 // una carátula externa válida. 120 if (previous.src !== next.src) { 121 useProgramCover(signalId); 122 return true; 123 } 124 return false;
125 } 126 127 function useProgramCover(signalId, options = {}) { 128 if (options.cancelPending !== false) cancelPendingExternal(signalId); 129 const saved = getProgramCover(signalId); 130 setPersistentCover(signalId, saved.src, options.sourceText || saved.sourceText); 131 } 132 133 function restorePersistentCover(signalId) { 134 temporaryUntil[signalId] = 0; 135 temporaryTimer[signalId] = null; 136 const saved = persistentCover[signalId]; 137 applyCover(signalId, saved?.src || getProgramCover(signalId).src, saved?.sourceText || 'RQST Cover'); 138 } 139 140 function showTemporaryCover(signalId, src, options = {}) { 141 const durationMs = Math.max(0, Number(options.durationMs || cfg().temporaryCoverMs || 10000)); 142 if (temporaryTimer[signalId]) clearTimeout(temporaryTimer[signalId]); 143 144 temporaryUntil[signalId] = Date.now() + durationMs; 145 applyCover(signalId, src || getProgramCover(signalId).src, options.sourceText || 'RQST Cover'); 146 temporaryTimer[signalId] = setTimeout(() => restorePersistentCover(signalId), durationMs); 147 } 148 149 function cancelPendingExternal(signalId) { 150 if (delayTimer[signalId]) { 151 clearTimeout(delayTimer[signalId]); 152 delayTimer[signalId] = null; 153 } 154 requestSeq[signalId] += 1; 155 } 156 157 function fallback(signalId) { 158 useProgramCover(signalId); 159 } 160 161 function normalizedQuery(...parts) { 162 return parts.map(clean).filter(Boolean).join(' '); 163 } 164 165 async function fetchDiscogsCover(signalId, query, seq) { 166 if (Date.now() < discogsDisabledUntil) return null; 167 const proxyUrl = cfg().discogsProxyUrl; 168 if (!proxyUrl) return null; 169 170 const url = new URL(proxyUrl); 171 if (query.artist) url.searchParams.set('artist', query.artist); 172 if (query.title) url.searchParams.set('title', query.title); 173 if (query.album) url.searchParams.set('album', query.album); 174 175 const data = await window.RQST_NET.fetchJson(url.toString(), { 176 timeoutMs: cfg().network?.timeoutMs || 7000, 177 cache: 'default' 178 }); 179 if (seq !== requestSeq[signalId]) return null; 180 181 if (data?.status === 429 || data?.reason === 'rate_limited') { 182 const retryMs = Number(data?.retryAfter || 0) * 1000; 183 discogsDisabledUntil = Date.now() + Math.max(retryMs, cfg().discogsCooldownMs || 600000); 184 return null; 185 } 186 187 const cover = data?.cover || null; 188 if (!cover) return null; 189 190 // Segunda barrera en el navegador: aunque el Worker entregue una imagen, 191 // no la aceptamos si el resultado no coincide realmente con el artista. 192 const match = clean(data?.match); 193 if (query.artist && !artistMatches(query.artist, match)) return null; 194 if (query.album && !titleMatches(query.album, match)) return null; 195 if (query.title && !titleMatches(query.title, match)) return null; 196 return cover; 197 } 198 199 function normalizeMatchText(value) { 200 return clean(value) 201 .normalize('NFD') 202 .replace(/[\u0300-\u036f]/g, '') 203 .toLowerCase() 204 .replace(/[^\p{L}\p{N}]+/gu, ' ') 205 .replace(/\s+/g, ' ') 206 .trim(); 207 } 208 209 function tokenCoverage(expected, actual) { 210 const wanted = normalizeMatchText(expected).split(' ').filter(
210Boolean); 211 const got = new Set(normalizeMatchText(actual).split(' ').filter(Boolean)); 212 if (!wanted.length) return 0; 213 return wanted.filter(token => got.has(token)).length / wanted.length; 214 } 215 216 function artistMatches(expected, actual) { 217 const a = normalizeMatchText(expected); 218 const b = normalizeMatchText(actual); 219 if (!a || !b) return false; 220 if (a === b) return true; 221 if (a.length >= 5 && (b.includes(a) || a.includes(b))) return true; 222 return tokenCoverage(a, b) >= 0.8; 223 } 224 225 function titleMatches(expected, actual) { 226 const a = normalizeMatchText(expected); 227 const b = normalizeMatchText(actual); 228 if (!a || !b) return false; 229 if (a === b || b.includes(a) || a.includes(b)) return true; 230 return tokenCoverage(a, b) >= 0.65; 231 } 232 233 function fetchDeezerCover(signalId, level, seq) { 234 return new Promise((resolve) => { 235 const query = encodeURIComponent(normalizedQuery(...level.generic)); 236 if (!query) return resolve(null); 237 238 const callbackName = `rqstDeezer_${signalId}_${Date.now()}_${Math.floor(Math.random() * 10000)}`; 239 const script = document.createElement('script'); 240 const cleanup = () => { 241 clearTimeout(timer); 242 delete window[callbackName]; 243 script.remove(); 244 }; 245 const timer = setTimeout(() => { 246 cleanup(); 247 resolve(null); 248 }, cfg().network?.timeoutMs || 7000); 249 250 window[callbackName] = (data) => { 251 const results = Array.isArray(data?.data) ? data.data : []; 252 const matched = results.find((item) => { 253 if (!artistMatches(level.artist, item?.artist?.name)) return false; 254 if (level.kind === 'album') return titleMatches(level.album, item?.album?.title); 255 if (level.kind === 'title') return titleMatches(level.title, item?.title); 256 return level.kind === 'artist'; 257 }); 258 const cover = matched?.album?.cover_big || matched?.album?.cover_medium; 259 cleanup(); 260 resolve(seq === requestSeq[signalId] ? (cover || null) : null); 261 }; 262 script.onerror = () => { 263 cleanup(); 264 resolve(null); 265 }; 266 script.src = `https://api.deezer.com/search?q=${query}&output=jsonp&callback=${callbackName}`; 267 document.body.appendChild(script); 268 }); 269 } 270 271 async function fetchITunesCover(signalId, level, seq) { 272 const query = normalizedQuery(...level.generic); 273 if (!query) return null; 274 const url = `https://itunes.apple.com/search?media=music&entity=song&limit=1&term=${encodeURIComponent(query)}`; 275 const data = await window.RQST_NET.fetchJson(url, { 276 timeoutMs: cfg().network?.timeoutMs || 7000, 277 cache: 'no-store' 278 }); 279 if (seq !== requestSeq[signalId]) return null; 280 const results = Array.isArray(data?.results) ? data.results : []; 281 const matched = results.find((item) => { 282 if (!artistMatches(level.artist, item?.artistName)) return false; 283 if (level.kind === 'album') return titleMatches(level.album, item?.collectionName); 284 if (level.kind === 'title') return titleMatches(level.title, item?.trackName); 285 return level.kind === 'artist'; 286 }); 287 const artwork = matched?.artworkUrl100; 288 return artwork ? artwork.replace('100x100bb', '600x600bb') : null; 289 } 290 291 function buildQueryLevels(artist, title, album) { 292 const levels = []; 293 if (artist && album) levels.push({ kind: 'album', label: 'artista + álbum', artist, album, title: '', discogs: { artist, album }, generic: [artist, album] }); 294 if (artist && title) levels.push({ kind: 'title', label: 'artista + tema', artist, title, album: '', discogs: { artist, title }, generic: [artist, title] }); 295 if (artist) levels.push({ kind: 'artist', label: 'artista exacto', artist, title: '', album: '', discogs: { artist }, generic: [artist] }); 296 return levels; 297 } 298 299 async function resolveExternal(signalId, artist, title, album, seq) { 300 const levels = buildQueryLevels(artist, title, album); 301 302 // 1) Discogs: de la consulta más precisa a la más amplia. 303 for (const level of levels) { 304 try { 305 const cover = await fetchDiscogsCover(signalId, level.discogs, seq); 306 if (cover && seq === requestSeq[signalId]) { 307 setPersistentCover(signalId, cover, 'Discogs'); 308 return true; 309 } 310 } catch (err) { 311 console.warn(`Discogs (${level.label}) unavailable:`, err); 312 } 313 } 314 315 // 2) Deezer: misma cascada de precisión. 316 for (const level of levels) {
317 try { 318 const cover = await fetchDeezerCover(signalId, level, seq); 319 if (cover && seq === requestSeq[signalId]) { 320 setPersistentCover(signalId, cover, 'Deezer'); 321 return true; 322 } 323 } catch (err) { 324 console.warn(`Deezer (${level.label}) unavailable:`, err); 325 } 326 } 327 328 // 3) iTunes: último respaldo externo, con la misma cascada. 329 for (const level of levels) { 330 try { 331 const cover = await fetchITunesCover(signalId, level, seq); 332 if (cover && seq === requestSeq[signalId]) { 333 setPersistentCover(signalId, cover, 'iTunes'); 334 return true; 335 } 336 } catch (err) { 337 console.warn(`iTunes (${level.label}) unavailable:`, err); 338 } 339 } 340 341 if (seq === requestSeq[signalId]) useProgramCover(signalId, { cancelPending: false }); 342 return false; 343 } 344 345 function fetchExternal(signalId, artistValue, titleValue, options = {}) { 346 const artist = isInvalidValue(artistValue) ? '' : clean(artistValue); 347 const title = isInvalidValue(titleValue) ? '' : clean(titleValue); 348 const album = isInvalidValue(options.album) ? '' : clean(options.album); 349 350 // Sin artista no existe una búsqueda suficientemente segura. 351 if (!artist) { 352 cancelPendingExternal(signalId); 353 useProgramCover(signalId, { cancelPending: false }); 354 return; 355 } 356 357 const key = trackKey(artist, title, album); 358 const now = Date.now(); 359 const staleMs = Math.max(60000, Number(cfg().metadataStaleMs || 900000)); 360 const sameTrack = lastTrackKey[signalId] === key; 361 362 if (!sameTrack) { 363 cancelPendingExternal(signalId); 364 lastTrackKey[signalId] = key; 365 trackSince[signalId] = now; 366 resolvedTrackKey[signalId] = ''; 367 staleAppliedKey[signalId] = ''; 368 lastAttemptAt[signalId] = 0; 369 } else if (trackSince[signalId] && now - trackSince[signalId] >= staleMs) { 370 // RadioDJ puede dejar nowplaying.txt congelado. A los 15 minutos 371 // volvemos a la carátula editorial del programa hasta un cambio real. 372 if (staleAppliedKey[signalId] !== key) { 373 cancelPendingExternal(signalId); 374 staleAppliedKey[signalId] = key; 375 useProgramCover(signalId, { cancelPending: false, sourceText: 'RQST Programa' }); 376 } 377 return; 378 } 379 380 if (staleAppliedKey[signalId] === key) return; 381 if (resolvedTrackKey[signalId] === key) return; 382 383 const retryMs = Math.max(15000, Number(cfg().coverRetryMs || 60000)); 384 if (sameTrack && now - lastAttemptAt[signalId] < retryMs) return; 385 386 if (delayTimer[signalId]) clearTimeout(delayTimer[signalId]); 387 lastAttemptAt[signalId] = now; 388 const seq = ++requestSeq[signalId]; 389 const configuredDelayMs = Math.max(0, Number(options.delayMs ?? cfg().coverUpdateDelayMs ?? 0)); 390 const delayMs = isTemporaryActive(signalId) ? 0 : configuredDelayMs; 391 392 const run = () => { 393 delayTimer[signalId] = null; 394 if (seq !== requestSeq[signalId]) return; 395 resolveExternal(signalId, artist, title, album, seq).then((resolved) => { 396 if (resolved && seq === requestSeq[signalId]) resolvedTrackKey[signalId] = key; 397 }); 398 }; 399 400 delayTimer[signalId] = delayMs > 0 ? setTimeout(run, delayMs) : null; 401 if (delayMs === 0) run(); 402 } 403 404 function init() { 405 [1, 2].forEach((signalId) => { 406 setProgramCover(signalId, cfg().invalidProgramCover || cfg().fallbackCover, 'RQST Programa'); 407 persistentCover[signalId] = getProgramCover(signalId); 408 applyCover(signalId, persistentCover[signalId].src, persistentCover[signalId].sourceText); 409 }); 410 } 411 412 window.RQST_COVERS = { 413 init, 414 applyCover, 415 setCover: setPersistentCover, 416 setPersistentCover, 417 setProgramCover, 418 getProgramCover, 419 useProgramCover, 420 showTemporaryCover, 421 isTemporaryActive, 422 cancelPendingExternal, 423 fallback, 424 fetchExternal, 425 isInvalidValue 426 }; 427})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.