PageSourceSearch

https://rqstradio.pages.dev/js/covers.js?v=71.1-a22

js rqstradio.pages.dev collected 2026-10-03 05:54:38 UTC 15,865 bytes, 427 lines download raw bytes

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.