1/** 2 * WXLI API Cache - Sistema centralizado de cache y sincronización 3 * Reduce llamadas duplicadas a la API entre ventanas/pestañas 4 * 5 * @author WXLI Tech 6 * @version 2.0.0 7 */ 8 9(function(window) { 10 'use strict'; 11 12 // ============================================================================ 13 // CONFIGURACIÃN 14 // ============================================================================ 15 const CONFIG = { 16 API_BASE: 'https://api.core.wxli.ai', 17 NOWPLAYING_ENDPOINT: '/nowplaying/1', 18 SCHEDULE_ENDPOINT: '/station/1/schedule', 19 20 // TTL dinámico según estado 21 CACHE_TTL_NORMAL: 10000, // 10 segundos en estado normal 22 CACHE_TTL_TRANSITION: 5000, // 5 segundos durante transición de canción 23 CACHE_TTL_BACKGROUND: 30000, // 30 segundos cuando la página está oculta 24 25 // LocalStorage keys para sincronización 26 STORAGE_KEY_NOWPLAYING: 'wxli-api-nowplaying-cache', 27 STORAGE_KEY_SCHEDULE: 'wxli-api-schedule-cache', 28 29 // BroadcastChannel para sincronización en tiempo real 30 BROADCAST_CHANNEL: 'wxli-api-sync' 31 }; 32 33 // ============================================================================ 34 // GENERADOR DE TOKEN DE SESIÃN 35 // ============================================================================ 36 function wxliToken() { 37 const ts = Math.floor(Date.now() / 1000); 38 return btoa('wxli:' + ts); 39 } 40 41 // ============================================================================ 42 // CACHE EN MEMORIA 43 // ============================================================================ 44 const memoryCache = { 45 nowplaying: { 46 data: null, 47 timestamp: 0, 48 pending: null, 49 songId: null 50 }, 51 schedule: { 52 data: null, 53 timestamp: 0, 54 pending: null 55 } 56 }; 57 58 // Estado de la aplicación 59 let isInTransition = false; 60 let isPageHidden = false; 61 let broadcastChannel = null; 62 63 // ============================================================================ 64 // BROADCAST CHANNEL PARA SINCRONIZACIÃN ENTRE VENTANAS 65 // ============================================================================ 66 function initBroadcastChannel() { 67 if ('BroadcastChannel' in window) { 68 try { 69 broadcastChannel = new BroadcastChannel(CONFIG.BROADCAST_CHANNEL); 70 71 broadcastChannel.onmessage = function(event) { 72 const message = event.data; 73 74 if (message.type === 'nowplaying-update' && message.data) { 75 // Otra ventana actualizó los datos, usar su cache 76 const now = Date.now(); 77 const messageAge = now - message.timestamp; 78 79 // Solo aceptar si el mensaje es reciente (< 2 segundos) 80 if (messageAge < 2000) { 81 memoryCache.nowplaying.data = message.data; 82 memoryCache.nowplaying.timestamp = message.timestamp; 83 memoryCache.nowplaying.songId = message.songId; 84 85 // Notificar a los listeners locales 86 window.dispatchEvent(new CustomEvent('wxli-nowplaying-updated', { 87 detail: { data: message.data, fromBroadcast: true } 88 })); 89 } 90 } else if (message.type === 'schedule-update' && message.data) { 91 const messageAge = Date.now() - message.timestamp; 92 if (messageAge < 5000) { 93 memoryCache.schedule.data = message.data; 94 memoryCache.schedule.timestamp = message.timestamp; 95 } 96 } 97 }; 98 } catch (e) { 99 } 100 } 101 } 102 103 // Broadcast data to other windows 104 function broadcastUpdate(type, data, songId) { 105 if (broadcastChannel) { 106 try { 107 broadcastChannel.postMessage({ 108 type: type, 109 data: data, 110 songId: songId, 111 timestamp: Date.now() 112 }); 113 } catch (e) { 114 // Silently fail if broadcast fails 115 } 116 } 117 } 118 119 // ============================================================================ 120 // LOCALSTORAGE FALLBACK PARA SINCRONIZACIÃN 121 // ============================================================================ 122 function saveToLocalStorage(key, data) { 123 try { 124 localStorage.setItem(key, JSON.stringify({ 125 data: data, 126 timestamp: Date.now() 127 })); 128 } catch (e) { 129 // Quota exceeded or other error - silently fail 130 } 131 } 132 133 function loadFromLocalStorage(key, maxAge) { 134 try { 135 const stored = localStorage.getItem(key); 136 if (stored) { 137 const parsed = JSON.parse(stored); 138 const age = Date.now() - parsed.timestamp; 139 if (age < maxAge) { 140 return parsed; 141 } 142 } 143 } catch (e) { 144 // Parse error - ignore 145 } 146 return null; 147 } 148 149 // ============================================================================ 150 // UTILIDAD: LIMPIEZA DE NOMBRES DE TRACKS 151 // ============================================================================ 152 function cleanTrackName(str) { 153 if (!str) return str; 154 return str 155 .replace(/\s{2,}/g, ' ') 156 .trim(); 157 } 158 159 function cleanNowPlayingData(data) { 160 if (!data) return data; 161 162 if (data.now_playing && data.now_playing.song) { 163 data.now_playing.song.title = cleanTrackName(data.now_playing.song.title); 164 data.now_playing.song.artist = cleanTrackName(data.now_playing.song.artist); 165 } 166 167 if (Array.isArray(data.song_history)) {
168 data.song_history.forEach(function(item) { 169 if (item.song) { 170 item.song.title = cleanTrackName(item.song.title); 171 item.song.artist = cleanTrackName(item.song.artist); 172 } 173 }); 174 } 175 176 return data; 177 } 178 179 // ============================================================================ 180 // FUNCIÃN PRINCIPAL: FETCH NOW PLAYING 181 // ============================================================================ 182 async function fetchNowPlaying(forceRefresh) { 183 const now = Date.now(); 184 const cache = memoryCache.nowplaying; 185 186 // Determinar TTL basado en estado 187 let ttl = CONFIG.CACHE_TTL_NORMAL; 188 if (isInTransition) { 189 ttl = CONFIG.CACHE_TTL_TRANSITION; 190 } else if (isPageHidden) { 191 ttl = CONFIG.CACHE_TTL_BACKGROUND; 192 } 193 194 // Si hay una petición pendiente, esperar a que termine (deduplicación) 195 if (cache.pending) { 196 return cache.pending; 197 } 198 199 // Si el cache es válido y no se fuerza refresh, usar cache 200 if (!forceRefresh && cache.data && (now - cache.timestamp) < ttl) { 201 return cache.data; 202 } 203 204 // Intentar cargar de localStorage (útil para nuevas pestañas) 205 if (!forceRefresh && !cache.data) { 206 const stored = loadFromLocalStorage(CONFIG.STORAGE_KEY_NOWPLAYING, ttl); 207 if (stored) { 208 cache.data = stored.data; 209 cache.timestamp = stored.timestamp; 210 return cache.data; 211 } 212 } 213 214 // Crear nueva petición con jitter para evitar thundering herd 215 const jitter = Math.random() * 500; 216 217 cache.pending = new Promise(function(resolve, reject) { 218 setTimeout(function() { 219 fetch(CONFIG.API_BASE + CONFIG.NOWPLAYING_ENDPOINT, { 220 headers: { 'X-WXLI-Session': wxliToken() }, 221 cache: 'no-store' 222 }) 223 .then(function(response) { 224 return response.json(); 225 }) 226 .then(function(data) { 227 const fetchTime = Date.now(); 228 229 // Limpiar paréntesis y corchetes de los nombres de tracks 230 cleanNowPlayingData(data); 231 232 // Actualizar cache en memoria 233 cache.data = data; 234 cache.timestamp = fetchTime; 235 cache.pending = null; 236 237 // Extraer songId para detección de cambios 238 const songId = data.now_playing && data.now_playing.song 239 ? (data.now_playing.song.id || data.now_playing.song.title) 240 : null; 241 cache.songId = songId; 242 243 // Guardar en localStorage para otras pestañas 244 saveToLocalStorage(CONFIG.STORAGE_KEY_NOWPLAYING, data); 245 246 // Broadcast a otras ventanas 247 broadcastUpdate('nowplaying-update', data, songId); 248 249 // Notificar listeners locales 250 window.dispatchEvent(new CustomEvent('wxli-nowplaying-updated', { 251 detail: { data: data, fromBroadcast: false } 252 })); 253 254 resolve(data); 255 }) 256 .catch(function(error) { 257 cache.pending = null; 258 reject(error); 259 }); 260 }, jitter); 261 }); 262 263 return cache.pending; 264 } 265 266 // ============================================================================ 267 // FUNCIÃN: FETCH SCHEDULE 268 // ============================================================================ 269 async function fetchSchedule(startDate, endDate, forceRefresh) { 270 const now = Date.now(); 271 const cache = memoryCache.schedule; 272 const ttl = isPageHidden ? CONFIG.CACHE_TTL_BACKGROUND : 60000; // 1 minuto para schedule 273 274 // Deduplicación 275 if (cache.pending) { 276 return cache.pending; 277 } 278 279 // Cache válido 280 if (!forceRefresh && cache.data && (now - cache.timestamp) < ttl) { 281 return cache.data; 282 } 283 284 // Construir URL con parámetros de fecha 285 let url = CONFIG.API_BASE + CONFIG.SCHEDULE_ENDPOINT; 286 if (startDate && endDate) { 287 url += '?start=' + startDate + '&end=' + endDate; 288 } 289 290 cache.pending = fetch(url, { 291 headers: { 'X-WXLI-Session': wxliToken() } 292 }) 293 .then(function(response) { 294 return response.json(); 295 }) 296 .then(function(data) { 297 cache.data = data; 298 cache.timestamp = Date.now(); 299 cache.pending = null; 300 301 // Guardar en localStorage 302 saveToLocalStorage(CONFIG.STORAGE_KEY_SCHEDULE, data); 303 304 // Broadcast a otras ventanas 305 broadcastUpdate('schedule-update', data, null); 306 307 return data; 308 }) 309 .catch(function(error) { 310 cache.pending = null; 311 throw error; 312 }); 313 314 return cache.pending; 315 } 316 317 // ============================================================================ 318 // FUNCIONES DE CONTROL DE ESTADO 319 // ============================================================================ 320 function setTransitionState(inTransition) { 321 isInTransition = inTransition; 322 } 323
324 function setPageHidden(hidden) { 325 isPageHidden = hidden; 326 } 327 328 function getCacheStats() { 329 return { 330 nowplaying: { 331 hasData: !!memoryCache.nowplaying.data, 332 age: memoryCache.nowplaying.timestamp ? Date.now() - memoryCache.nowplaying.timestamp : null, 333 songId: memoryCache.nowplaying.songId 334 }, 335 schedule: { 336 hasData: !!memoryCache.schedule.data, 337 age: memoryCache.schedule.timestamp ? Date.now() - memoryCache.schedule.timestamp : null 338 }, 339 isInTransition: isInTransition, 340 isPageHidden: isPageHidden 341 }; 342 } 343 344 function invalidateCache(type) { 345 if (type === 'nowplaying' || type === 'all') { 346 memoryCache.nowplaying.data = null; 347 memoryCache.nowplaying.timestamp = 0; 348 } 349 if (type === 'schedule' || type === 'all') { 350 memoryCache.schedule.data = null; 351 memoryCache.schedule.timestamp = 0; 352 } 353 } 354 355 // Obtener datos cacheados sin hacer fetch (útil para UI) 356 function getCachedNowPlaying() { 357 return memoryCache.nowplaying.data; 358 } 359 360 function getCachedSchedule() { 361 return memoryCache.schedule.data; 362 } 363 364 // ============================================================================ 365 // VISIBILITY CHANGE HANDLER 366 // ============================================================================ 367 document.addEventListener('visibilitychange', function() { 368 setPageHidden(document.hidden); 369 370 if (!document.hidden) { 371 // Página visible de nuevo - forzar refresh si el cache es muy viejo 372 const cache = memoryCache.nowplaying; 373 if (cache.data && (Date.now() - cache.timestamp) > CONFIG.CACHE_TTL_BACKGROUND) { 374 fetchNowPlaying(true).catch(function() {}); 375 } 376 } 377 }); 378 379 // ============================================================================ 380 // INICIALIZACIÃN 381 // ============================================================================ 382 initBroadcastChannel(); 383 384 // Cerrar BroadcastChannel al descargar la página para liberar recursos 385 window.addEventListener('pagehide', function() { 386 if (broadcastChannel) { 387 broadcastChannel.close(); 388 broadcastChannel = null; 389 } 390 }, { capture: true }); 391 392 // ============================================================================ 393 // EXPORTAR API PÃBLICA 394 // ============================================================================ 395 window.WXLICache = { 396 // Funciones principales 397 fetchNowPlaying: fetchNowPlaying, 398 fetchSchedule: fetchSchedule, 399 400 // Token de sesión (para compatibilidad) 401 wxliToken: wxliToken, 402 403 // Control de estado 404 setTransitionState: setTransitionState, 405 setPageHidden: setPageHidden, 406 407 // Utilidades 408 getCacheStats: getCacheStats, 409 invalidateCache: invalidateCache, 410 getCachedNowPlaying: getCachedNowPlaying, 411 getCachedSchedule: getCachedSchedule, 412 413 // Configuración (solo lectura) 414 CONFIG: Object.freeze(CONFIG) 415 }; 416 417 // Alias para compatibilidad con código existente 418 window.wxliToken = wxliToken; 419 window.wxliFetchNowPlaying = fetchNowPlaying; 420 421 422})(window);
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.