"use server"; import axios from 'axios'; import { cache } from 'react'; import crypto from 'crypto'; import { cookies } from 'next/headers'; import db from '@/_DB/db'; // Usando la conexión a DB existente // Configuración base de la API const QOBUZ_API_BASE = 'https://www.qobuz.com/api.json/0.2/'; const QOBUZ_APP_ID = '579939560'; const QOBUZ_SECRET = 'fa31fc13e7a28e7d70bb61e91aa9e178'; const QOBUZ_AUTH_TOKENS = ["dB9fpnEf7mApNpVWyLkOb0JhilAo_LQOC7izjndMzK2tkZrFlq5XTs-tGMBhVArJuNPTGnVcCbuSDJ2PXOBd1Q"]; // Constantes para rendimiento y tiempos de caché const CACHE_DURATION = 1000 * 60 * 60; // 60 minutos const REQUEST_TIMEOUT = 8000; // 8 segundos const RETRY_ATTEMPTS = 2; const RETRY_DELAY = 500; // 500ms const PRELOAD_ALL_PAGES = true; // Precargar todas las páginas const BUFFER_PRELOAD_DELAY = 3000; // 3 segundos para precargar buffer // Caché en memoria para datos y streams const memoryCache = { tracks: {}, streams: {}, pages: {}, timestamps: {}, inFlight: {} // Para evitar solicitudes duplicadas }; /** * Crea una instancia de Axios con configuración optimizada * @param {string} proxyUrl - URL del proxy (opcional) * @returns {AxiosInstance} - Instancia configurada de Axios */ function createAxiosInstance(proxyUrl = '') { const baseURL = proxyUrl ? `${proxyUrl}${encodeURIComponent(QOBUZ_API_BASE)}` : QOBUZ_API_BASE; return axios.create({ baseURL, timeout: REQUEST_TIMEOUT, headers: { 'X-App-Id': QOBUZ_APP_ID, 'X-User-Auth-Token': QOBUZ_AUTH_TOKENS[0], 'X-App-Secret': QOBUZ_SECRET, 'Content-Type': 'application/json', }, // Habilitar compresión para reducir tamaño de datos decompress: true }); } // Instancia principal de la API let qobuzAPI = createAxiosInstance(); /** * Verifica si un ítem está en caché y es válido * @param {string} cacheKey - Clave del ítem en caché * @param {string} cacheType - Tipo de caché ('tracks', 'streams', 'pages') * @returns {boolean} - true si el ítem está en caché y es válido */ function isValidCacheItem(cacheKey, cacheType) { const now = Date.now(); return ( memoryCache[cacheType][cacheKey] && memoryCache.timestamps[cacheKey] && now - memoryCache.timestamps[cacheKey] < CACHE_DURATION ); } /** * Guarda un ítem en la caché * @param {string} cacheKey - Clave del ítem * @param {string} cacheType - Tipo de caché ('tracks', 'streams', 'pages') * @param {any} data - Datos a almacenar */ function cacheItem(cacheKey, cacheType, data) { memoryCache[cacheType][cacheKey] = data; memoryCache.timestamps[cacheKey] = Date.now(); } /** * Función con reintento para solicitudes a la API * @param {Function} requestFn - Función que realiza la solicitud * @param {number} maxRetries - Número máximo de reintentos * @returns {Promise} - Respuesta de la API */ async function withRetry(requestFn, maxRetries = RETRY_ATTEMPTS) { let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { // Esperar un tiempo exponencial entre reintentos if (attempt > 0) { await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * Math.pow(2, attempt - 1))); } const result = await requestFn(); return result; } catch (error) { console.warn(`🔄 Reintento ${attempt + 1}/${maxRetries} fallido:`, error.message); lastError = error; // Si es un error de autenticación, no reintentar if (error.response && (error.response.status === 401 || error.response.status === 403)) { break; } } } throw lastError; } /** * Busca datos en la API de Qobuz con gestión de errores y caché * @param {string} endpoint - Endpoint de la API * @param {Object} params - Parámetros de la consulta * @returns {Promise} - Datos de la respuesta */ export async function fetchQobuzData(endpoint, params = {}) { // Crear una clave única para el caché const cacheKey = `${endpoint}_${JSON.stringify(params)}`; // Evitar solicitudes duplicadas en vuelo if (memoryCache.inFlight[cacheKey]) { try { return await memoryCache.inFlight[cacheKey]; } catch (error) { console.warn('Solicitud en vuelo falló, reintentando'); } } // Verificar caché if (isValidCacheItem(cacheKey, 'tracks')) { return memoryCache.tracks[cacheKey]; } const queryParams = { app_id: QOBUZ_APP_ID, ...params }; // Crear promesa para esta solicitud const requestPromise = (async () => { try { // Usar withRetry para manejar reintentos automáticos const response = await withRetry(async () => { return await qobuzAPI.get(endpoint, { params: queryParams }); }); // Guardar en caché cacheItem(cacheKey, 'tracks', response.data); return response.data; } catch (error) { console.error(`❌ Error al obtener datos de Qobuz (${endpoint}):`, error.message); // En caso de error, intentar usar caché vencida si existe if (memoryCache.tracks[cacheKey]) { console.warn(`⚠️ Usando caché vencida para: ${endpoint}`); return memoryCache.tracks[cacheKey]; } throw error; } finally { // Eliminar promesa de la lista de solicitudes en vuelo delete memoryCache.inFlight[cacheKey]; } })(); // Registrar promesa en vuelo memoryCache.inFlight[cacheKey] = requestPromise; // Esperar resultado try { return await requestPromise; } catch (error) { return { error: error.message }; } } /** * Obtiene la URL de streaming para una pista con manejo de caché y fallbacks * @param {string|number} trackId - ID de la pista * @returns {Promise} - URLsaveTrackToLocalHistory de streaming o null si hay error */ export async function fetchTrackStreamingUrl(trackId) { console.log(`Iniciando obtención de URL para pista ${trackId}`); // Verificar caché de streams if (isValidCacheItem(trackId, 'streams')) { console.log(`URL encontrada en caché para pista ${trackId}`); return memoryCache.streams[trackId]; } // Primero verificar si existe en la base de datos try { const [existingTrack] = await db.execute( 'SELECT url FROM musicas WHERE id = ? AND url IS NOT NULL AND url != "" LIMIT 1', [trackId] ); if (existingTrack.length > 0 && existingTrack[0].url) { console.log(`URL encontrada en la base de datos para pista ${trackId}`); // Actualizar caché cacheItem(trackId, 'streams', existingTrack[0].url); return existingTrack[0].url; } } catch (dbError) { console.warn(`Error al buscar URL en la base de datos para pista ${trackId}:`, dbError); } // Evitar solicitudes duplicadas const cacheKey = `stream_${trackId}`; if (memoryCache.inFlight[cacheKey]) { try { return await memoryCache.inFlight[cacheKey]; } catch (error) { console.warn('Solicitud de stream en vuelo falló, reintentando'); } } // Crear promesa para solicitud a Qobuz const requestPromise = (async () => { try { console.log(`Solicitando URL de streaming a Qobuz para pista ${trackId}`); const token = QOBUZ_AUTH_TOKENS[0]; const formatId = 5; // MP3 320 kbps const intent = 'stream'; const requestTs = Math.floor(Date.now() / 1000); // Generar firma (sig) con los parámetros requeridos const sigBase = `trackgetFileUrlformat_id${formatId}intent${intent}track_id${trackId}${requestTs}${QOBUZ_SECRET}`; const requestSig = crypto.createHash('md5').update(sigBase).digest('hex'); // Usar withRetry para manejar reintentos automáticos const response = await withRetry(async () => { return await axios.get(`${QOBUZ_API_BASE}track/getFileUrl`, { params: { track_id: trackId, format_id: formatId, intent: intent, app_id: QOBUZ_APP_ID, user_auth_token: token, request_ts: requestTs, request_sig: requestSig, }, timeout: REQUEST_TIMEOUT }); }); // Guardar en caché y base de datos if (response.data && response.data.url) { cacheItem(trackId, 'streams', response.data.url); // Actualizar URL en la base de datos si existe la pista try { await db.execute( 'UPDATE musicas SET url = ? WHERE id = ?', [response.data.url, trackId] ); } catch (dbError) { console.warn(`Error al actualizar URL en la base de datos para pista ${trackId}:`, dbError); } return response.data.url; } // Si no hay URL, intentar obtener más detalles para diagnosticar console.warn(`No se encontró URL en la respuesta para pista ${trackId}:`, response.data); return null; } catch (error) { // Intentar obtener detalles del error para mejor diagnóstico const errorDetails = error.response?.data || error.message; console.error(`❌ Error obteniendo stream para pista ${trackId}:`, errorDetails); // Intentar con URL fallback para pruebas (solo desarrollo) const fallbackUrl = `https://example.com/audio/track_${trackId}.mp3`; console.warn(`⚠️ Usando URL fallback para pista ${trackId}: ${fallbackUrl}`); // Guardar fallback en caché para evitar reintentos fallidos cacheItem(trackId, 'streams', fallbackUrl); return fallbackUrl; } finally { delete memoryCache.inFlight[cacheKey]; } })(); // Registrar promesa en vuelo memoryCache.inFlight[cacheKey] = requestPromise; try { const result = await requestPromise; console.log(`URL de streaming obtenida para pista ${trackId}: ${result ? 'Sí' : 'No'}`); return result; } catch (error) { console.error(`Error final al obtener URL para pista ${trackId}:`, error); return null; } } /** * Precarga URLs de streaming para un conjunto de pistas * @param {Array} tracks - Lista de pistas * @returns {Promise} - Mapa de IDs a URLs de streaming */ export async function preloadTrackStreams(tracks) { if (!tracks || !tracks.length) return {}; // Precargar todas las pistas const tracksToPreload = tracks; const streamUrlMap = {}; // Priorizar pistas sin URL en caché const uncachedTracks = tracksToPreload.filter(track => !isValidCacheItem(track.id, 'streams')); // Si todas están en caché, no hacer nada if (uncachedTracks.length === 0) { // Devolver las URLs en caché tracksToPreload.forEach(track => { if (isValidCacheItem(track.id, 'streams')) { streamUrlMap[track.id] = memoryCache.streams[track.id]; } }); return streamUrlMap; } // Crear un array de promesas para solicitudes en paralelo (máx. 3 en paralelo) const batchSize = 3; for (let i = 0; i < uncachedTracks.length; i += batchSize) { const batch = uncachedTracks.slice(i, i + batchSize); // Esperar a que termine este lote antes de pasar al siguiente await Promise.all(batch.map(async track => { try { const streamUrl = await fetchTrackStreamingUrl(track.id); if (streamUrl) { streamUrlMap[track.id] = streamUrl; } } catch (err) { console.warn(`No se pudo precargar: ${track.title}`, err); } })); } // También incluir las que ya estaban en caché tracksToPreload.forEach(track => { if (isValidCacheItem(track.id, 'streams') && !streamUrlMap[track.id]) { streamUrlMap[track.id] = memoryCache.streams[track.id]; } }); console.log(`🎵 Precargados ${Object.keys(streamUrlMap).length} streams`); return streamUrlMap; } /** * Precarga inteligente para páginas adyacentes * @param {number} currentPage - Página actual * @param {number} totalPages - Total de páginas * @param {number} limit - Elementos por página * @returns {Promise} - Resultado de la precarga */ export async function preloadAdjacentPages(currentPage, totalPages, limit = 20) { if (!currentPage || !totalPages) return { success: false, reason: 'Parámetros inválidos' }; const pagesToPreload = []; // Si está habilitado precargar todas las páginas if (PRELOAD_ALL_PAGES) { // Precargar todas las páginas en orden de prioridad for (let i = 1; i <= totalPages; i++) { if (i !== currentPage) { pagesToPreload.push(i); } } } else { // Solo precargar páginas adyacentes const maxBuffer = 2; // Precargar 2 páginas adelante y atrás // Determinar qué páginas precargar for (let i = 1; i <= maxBuffer; i++) { // Precargar siguiente página si existe if (currentPage + i <= totalPages) { pagesToPreload.push(currentPage + i); } // Precargar página anterior si existe if (currentPage - i >= 1) { pagesToPreload.push(currentPage - i); } } } // Filtrar páginas ya en caché o en proceso const filteredPages = pagesToPreload.filter(page => { const cacheKey = `home_${page}_${limit}`; return !isValidCacheItem(cacheKey, 'pages') && !memoryCache.inFlight[cacheKey]; }); // Precargar en segundo plano con delay escalonado filteredPages.forEach((page, index) => { setTimeout(() => { getHomePageData(page, limit) .catch(err => { console.warn(`Error precargando página ${page}:`, err); }); }, index * 500 + BUFFER_PRELOAD_DELAY); // Delay adicional para precargar }); return { success: true, pagesQueued: filteredPages }; } /** * Obtiene datos de la página principal con gestión de caché y precargas * @param {number} page - Número de página * @param {number} limit - Límite de elementos por página * @returns {Promise} - Datos para la página principal */ export const getHomePageData = cache(async (page = 1, limit = 20) => { try { const cacheKey = `home_${page}_${limit}`; // Verificar caché de páginas if (isValidCacheItem(cacheKey, 'pages')) { return memoryCache.pages[cacheKey]; } // Evitar solicitudes duplicadas if (memoryCache.inFlight[cacheKey]) { try { return await memoryCache.inFlight[cacheKey]; } catch (error) { console.warn('Solicitud en vuelo falló, reintentando'); } } // Crear promesa para esta solicitud const requestPromise = (async () => { const offset = (page - 1) * limit; // Búsqueda principal const data = await fetchQobuzData('track/search', { query: 'martin', limit, offset, type: 'tracks' }); if (!data || data.error || !data.tracks || !data.tracks.items) { const errorMsg = data?.error || 'Respuesta de API inválida'; throw new Error(errorMsg); } const items = data.tracks.items || []; // Resultado final const result = { featured: items, totalPages: Math.ceil(data.tracks.total / limit) || 1 }; // Guardar en caché cacheItem(cacheKey, 'pages', result); return result; })(); // Registrar promesa en vuelo memoryCache.inFlight[cacheKey] = requestPromise; try { const result = await requestPromise; // Si es la primera página, iniciar precarga avanzada if (page === 1) { // Precargar todos los streams de la primera página inmediatamente preloadTrackStreams(result.featured) .then(() => { // Después de precargar streams, iniciar precarga de páginas adicionales preloadAdjacentPages(page, result.totalPages, limit) .catch(err => { console.error('Error en precarga de páginas adicionales:', err); }); }) .catch(err => { console.error('Error en precarga inicial:', err); }); } return result; } catch (error) { console.error('❌ Error en getHomePageData:', error.message); // Intentar usar caché vencida si hay error if (memoryCache.pages[cacheKey]) { console.warn('⚠️ Usando caché vencida para la página principal'); return memoryCache.pages[cacheKey]; } return { featured: [], totalPages: 1, error: error.message }; } finally { delete memoryCache.inFlight[cacheKey]; } } catch (error) { console.error('Error general en getHomePageData:', error); return { featured: [], totalPages: 1, error: 'Error inesperado al cargar datos' }; } }); /** * Limpia la caché en memoria * @param {string} type - Tipo de caché a limpiar ('all', 'tracks', 'streams', 'pages') */ export async function clearCache(type = 'all') { if (type === 'all' || type === 'tracks') { memoryCache.tracks = {}; } if (type === 'all' || type === 'streams') { memoryCache.streams = {}; } if (type === 'all' || type === 'pages') { memoryCache.pages = {}; } if (type === 'all') { memoryCache.timestamps = {}; // No limpiamos inFlight para evitar problemas con solicitudes en curso } console.log(`🧹 Caché limpiada: ${type}`); return { success: true, type }; } /** * Obtiene el listado de favoritos del usuario * @returns {Promise} - Lista de favoritos */ export async function getUserFavorites() { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return []; } // Consultar favoritos const [rows] = await db.execute(` SELECT f.musica_id, f.fecha_agregado, m.titulo, m.artista, m.album, m.url FROM favoritos f LEFT JOIN musicas m ON f.musica_id = m.id WHERE f.usuario_id = ? ORDER BY f.fecha_agregado DESC `, [userId]); return rows; } catch (error) { console.error('Error al obtener favoritos:', error); return []; } } /** * Obtiene detalles adicionales de una pista * @param {string|number} trackId - ID de la pista * @returns {Promise} - Detalles de la pista */ export async function getTrackDetails(trackId) { if (!trackId) return null; const cacheKey = `track_${trackId}`; // Verificar caché if (isValidCacheItem(cacheKey, 'tracks')) { return memoryCache.tracks[cacheKey]; } // Evitar solicitudes duplicadas if (memoryCache.inFlight[cacheKey]) { try { return await memoryCache.inFlight[cacheKey]; } catch (error) { console.warn('Solicitud de detalles en vuelo falló, reintentando'); } } const requestPromise = (async () => { try { const data = await fetchQobuzData('track/get', { track_id: trackId }); if (data) { cacheItem(cacheKey, 'tracks', data); } return data; } catch (error) { console.error(`❌ Error al obtener detalles de pista ${trackId}:`, error.message); throw error; } finally { delete memoryCache.inFlight[cacheKey]; } })(); // Registrar promesa en vuelo memoryCache.inFlight[cacheKey] = requestPromise; try { return await requestPromise; } catch (error) { return null; } } /** * Verifica si el usuario tiene una sesión activa * @returns {Promise<{isLoggedIn: boolean, userId: number|null}>} - Estado de autenticación */ export async function isUserAuthenticated() { try { // Verificar si existe una cookie de sesión const cookieStore = cookies(); const token = cookieStore.get('authToken')?.value; if (!token) { return { isLoggedIn: false, userId: null }; } // Verificar si el token es válido en la base de datos try { const [rows] = await db.execute( 'SELECT usuario_id FROM sesiones WHERE token = ? AND fecha_expiracion > NOW() AND activo = TRUE LIMIT 1', [token] ); if (rows.length > 0) { return { isLoggedIn: true, userId: rows[0].usuario_id }; } return { isLoggedIn: false, userId: null }; } catch (dbError) { console.error('Error al verificar sesión en DB:', dbError); return { isLoggedIn: false, userId: null }; } } catch (error) { console.error('Error al verificar autenticación:', error); return { isLoggedIn: false, userId: null }; } } /** * Sincroniza favoritos desde localStorage a la base de datos * @param {Object} localFavorites - Favoritos almacenados en localStorage * @returns {Promise<{success: boolean, syncedCount: number}>} - Resultado de la sincronización */ export async function syncFavoritesToDB(localFavorites) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, syncedCount: 0, error: 'Usuario no autenticado' }; } // Verificar si hay favoritos para sincronizar if (!localFavorites || Object.keys(localFavorites).length === 0) { return { success: true, syncedCount: 0 }; } let syncedCount = 0; // Procesar cada favorito for (const trackId in localFavorites) { try { // Verificar si ya existe este favorito const [existingRows] = await db.execute( 'SELECT id FROM favoritos WHERE usuario_id = ? AND musica_id = ? LIMIT 1', [userId, trackId] ); // Si no existe, insertarlo if (existingRows.length === 0) { await db.execute( 'INSERT INTO favoritos (usuario_id, musica_id, fecha_agregado) VALUES (?, ?, NOW())', [userId, trackId] ); syncedCount++; } } catch (itemError) { console.error(`Error al sincronizar favorito ${trackId}:`, itemError); } } return { success: true, syncedCount, message: `${syncedCount} favoritos sincronizados correctamente` }; } catch (error) { console.error('Error al sincronizar favoritos:', error); return { success: false, syncedCount: 0, error: 'Error al sincronizar favoritos con la base de datos' }; } } /** * Sincroniza descargas desde localStorage a la base de datos * @param {Object} localDownloads - Descargas almacenadas en localStorage * @returns {Promise<{success: boolean, syncedCount: number}>} - Resultado de la sincronización */ export async function syncDownloadsToDB(localDownloads) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, syncedCount: 0, error: 'Usuario no autenticado' }; } // Verificar si hay descargas para sincronizar if (!localDownloads || Object.keys(localDownloads).length === 0) { return { success: true, syncedCount: 0 }; } let syncedCount = 0; // Procesar cada descarga for (const trackId in localDownloads) { try { const downloadInfo = localDownloads[trackId]; const count = downloadInfo.count || 1; // Buscar si ya existe un registro de descarga para esta canción const [existingRows] = await db.execute( 'SELECT id, contador FROM descargas WHERE musica_id = ? LIMIT 1', [trackId] ); if (existingRows.length > 0) { // Actualizar contador existente const currentCount = existingRows[0].contador; const newCount = currentCount + count; await db.execute( 'UPDATE descargas SET contador = ?, fecha_descarga = NOW() WHERE id = ?', [newCount, existingRows[0].id] ); } else { // Crear nuevo registro await db.execute( 'INSERT INTO descargas (musica_id, contador, fecha_descarga) VALUES (?, ?, NOW())', [trackId, count] ); } // Registrar en el historial de descarga del usuario await db.execute( 'INSERT INTO historial_musica (usuario_id, musica_id, fecha_reproduccion) VALUES (?, ?, NOW())', [userId, trackId] ); syncedCount++; } catch (itemError) { console.error(`Error al sincronizar descarga ${trackId}:`, itemError); } } return { success: true, syncedCount, message: `${syncedCount} descargas sincronizadas correctamente` }; } catch (error) { console.error('Error al sincronizar descargas:', error); return { success: false, syncedCount: 0, error: 'Error al sincronizar descargas con la base de datos' }; } } /** * Guarda una pista como favorita en la base de datos * @param {string|number} trackId - ID de la pista * @returns {Promise} - Resultado de la operación */ export async function addToFavorites(trackId) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, error: 'Usuario no autenticado' }; } // Verificar si ya existe este favorito const [existingRows] = await db.execute( 'SELECT id FROM favoritos WHERE usuario_id = ? AND musica_id = ? LIMIT 1', [userId, trackId] ); // Si ya existe, no hacer nada if (existingRows.length > 0) { return { success: true, message: 'La pista ya estaba en favoritos' }; } // Insertar nuevo favorito await db.execute( 'INSERT INTO favoritos (usuario_id, musica_id, fecha_agregado) VALUES (?, ?, NOW())', [userId, trackId] ); return { success: true, message: 'Añadido a favoritos correctamente' }; } catch (error) { console.error('Error al añadir a favoritos:', error); return { success: false, error: 'Error al añadir a favoritos' }; } } /** * Elimina una pista de favoritos * @param {string|number} trackId - ID de la pista * @returns {Promise} - Resultado de la operación */ export async function removeFromFavorites(trackId) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, error: 'Usuario no autenticado' }; } // Eliminar de favoritos await db.execute( 'DELETE FROM favoritos WHERE usuario_id = ? AND musica_id = ?', [userId, trackId] ); return { success: true, message: 'Eliminado de favoritos correctamente' }; } catch (error) { console.error('Error al eliminar de favoritos:', error); return { success: false, error: 'Error al eliminar de favoritos' }; } } /** * Registra una descarga en la base de datos * @param {string|number} trackId - ID de la pista * @returns {Promise} - Resultado de la operación */ export async function registerDownload(trackId) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); // Buscar si ya existe un registro de descarga para esta canción const [existingRows] = await db.execute( 'SELECT id, contador FROM descargas WHERE musica_id = ? LIMIT 1', [trackId] ); if (existingRows.length > 0) { // Actualizar contador existente const currentCount = existingRows[0].contador; await db.execute( 'UPDATE descargas SET contador = ?, fecha_descarga = NOW() WHERE id = ?', [currentCount + 1, existingRows[0].id] ); } else { // Crear nuevo registro await db.execute( 'INSERT INTO descargas (musica_id, contador, fecha_descarga) VALUES (?, 1, NOW())', [trackId] ); } // Si el usuario está logueado, registrar en su historial if (isLoggedIn && userId) { await db.execute( 'INSERT INTO historial_musica (usuario_id, musica_id, fecha_reproduccion) VALUES (?, ?, NOW())', [userId, trackId] ); } return { success: true, message: 'Descarga registrada correctamente' }; } catch (error) { console.error('Error al registrar descarga:', error); return { success: false, error: 'Error al registrar descarga' }; } } /** * Elimina una pista específica del historial del usuario * @param {string|number} trackId - ID de la pista a eliminar * @returns {Promise} - Resultado de la operación */ export async function removeTrackFromHistory(trackId) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, error: 'Usuario no autenticado' }; } // Eliminar la pista específica del historial await db.execute( 'DELETE FROM historial_musica WHERE usuario_id = ? AND musica_id = ?', [userId, trackId] ); return { success: true, message: 'Pista eliminada del historial correctamente' }; } catch (error) { console.error('Error al eliminar pista del historial:', error); return { success: false, error: 'Error al eliminar pista del historial' }; } } /** * Registra una reproducción en el historial * @param {string|number} trackId - ID de la pista * @returns {Promise} - Resultado de la operación */ export async function registerPlayHistorial(trackId) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, error: 'Usuario no autenticado' }; } // Obtener información de la pista si existe const [existingTrack] = await db.execute( 'SELECT id, titulo, artista, album, url FROM musicas WHERE id = ? LIMIT 1', [trackId] ); // Si no existe la pista en la base de datos, intentamos obtener la información desde Qobuz if (existingTrack.length === 0) { try { // Intentar obtener detalles desde Qobuz const trackDetails = await getTrackDetails(trackId); if (trackDetails && trackDetails.id) { // Guardar la pista en la base de datos await saveMissingTrack({ id: trackId, title: trackDetails.title, artist: trackDetails.artist, album: trackDetails.album }); } } catch (err) { console.warn(`No se pudo obtener detalles para la pista ${trackId}:`, err); } } // Registrar en historial await db.execute( 'INSERT INTO historial_musica (usuario_id, musica_id, fecha_reproduccion) VALUES (?, ?, NOW())', [userId, trackId] ); return { success: true, message: 'Reproducción registrada correctamente' }; } catch (error) { console.error('Error al registrar reproducción:', error); return { success: false, error: 'Error al registrar reproducción' }; } } /** * Obtiene el historial de reproducciones del usuario con información completa de las pistas * @param {number} limit - Límite de elementos a obtener * @returns {Promise} - Historial de reproducciones */ export async function getUserPlayHistory(limit = 50) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return []; } console.log(`Obteniendo historial para usuario ${userId}, límite ${limit}`); // Consultar historial con join a tabla de músicas const [rows] = await db.execute(` SELECT h.musica_id, h.fecha_reproduccion, m.titulo, m.artista, m.album, m.url, m.duracion FROM historial_musica h LEFT JOIN musicas m ON h.musica_id = m.id WHERE h.usuario_id = ? ORDER BY h.fecha_reproduccion DESC LIMIT ? `, [userId, limit]); // Procesar los resultados y obtener detalles adicionales si es necesario const processedResults = await Promise.all(rows.map(async (row) => { let trackInfo = { musica_id: row.musica_id, fecha_reproduccion: row.fecha_reproduccion, titulo: row.titulo || 'Sin título', artista: row.artista || 'Artista desconocido', album: { title: row.album || 'Álbum desconocido', image: { small: '/placeholder.svg' }, cover_small: '/placeholder.svg' }, url: row.url, duracion: row.duracion }; // Si los datos están incompletos, intentar obtener detalles de Qobuz if (row.titulo === 'Sin título' || row.titulo === null || row.artista === 'Artista desconocido' || row.artista === null) { try { const details = await getTrackDetails(row.musica_id); if (details && details.title) { // Actualizar en la base de datos await db.execute(` UPDATE musicas SET titulo = ?, artista = ?, album = ? WHERE id = ? `, [ details.title, details.artist?.name || 'Artista desconocido', details.album?.title || 'Álbum desconocido', row.musica_id ]); // Actualizar objeto de resultado trackInfo.titulo = details.title; trackInfo.artista = details.artist?.name || 'Artista desconocido'; trackInfo.album = { title: details.album?.title || 'Álbum desconocido', image: details.album?.image || { small: '/placeholder.svg' }, cover_small: details.album?.cover_small || '/placeholder.svg' }; } } catch (err) { console.warn(`No se pudieron obtener detalles para pista ${row.musica_id}:`, err); } } // Añadir función para cargar URL de streaming trackInfo.loadStreamUrl = async () => { if (row.url) return row.url; return await fetchTrackStreamingUrl(row.musica_id); }; return trackInfo; })); console.log(`Procesados ${processedResults.length} registros de historial`); return processedResults; } catch (error) { console.error('Error al obtener historial de reproducciones:', error); return []; } } /** * Limpia el historial de reproducciones del usuario * @returns {Promise} - Resultado de la operación */ export async function clearPlayHistory() { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, error: 'Usuario no autenticado' }; } // Eliminar todo el historial del usuario await db.execute( 'DELETE FROM historial_musica WHERE usuario_id = ?', [userId] ); return { success: true, message: 'Historial eliminado correctamente' }; } catch (error) { console.error('Error al limpiar historial:', error); return { success: false, error: 'Error al limpiar historial' }; } } /** * Obtiene el historial de reproducciones agrupado por día * @param {number} days - Número de días a obtener * @returns {Promise} - Historial agrupado por día */ export async function getPlayHistoryByDay(days = 7) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return {}; } // Obtener historial de los últimos días const [rows] = await db.execute(` SELECT DATE(h.fecha_reproduccion) as dia, COUNT(*) as reproducciones FROM historial_musica h WHERE h.usuario_id = ? AND h.fecha_reproduccion >= DATE_SUB(CURRENT_DATE(), INTERVAL ? DAY) GROUP BY DATE(h.fecha_reproduccion) ORDER BY dia ASC `, [userId, days]); // Formatear resultado const result = {}; const today = new Date(); // Inicializar todos los días con 0 reproducciones for (let i = 0; i < days; i++) { const date = new Date(today); date.setDate(today.getDate() - (days - 1 - i)); const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD result[dateStr] = 0; } // Llenar con datos reales rows.forEach(row => { // Convertir fecha del formato 'YYYY-MM-DD' const dateStr = new Date(row.dia).toISOString().split('T')[0]; result[dateStr] = row.reproducciones; }); return result; } catch (error) { console.error('Error al obtener historial por día:', error); return {}; } } /** * Obtiene las pistas más reproducidas por el usuario * @param {number} limit - Límite de elementos * @returns {Promise} - Pistas más reproducidas */ export async function getMostPlayedTracks(limit = 10) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return []; } // Consultar las pistas más reproducidas const [rows] = await db.execute(` SELECT h.musica_id, COUNT(*) as reproducciones, MAX(h.fecha_reproduccion) as ultima_reproduccion, m.titulo, m.artista, m.album, m.url FROM historial_musica h LEFT JOIN musicas m ON h.musica_id = m.id WHERE h.usuario_id = ? GROUP BY h.musica_id ORDER BY reproducciones DESC, ultima_reproduccion DESC LIMIT ? `, [userId, limit]); return rows; } catch (error) { console.error('Error al obtener pistas más reproducidas:', error); return []; } } /** * Sincroniza el historial de reproducciones desde localStorage a la base de datos * @param {Object} localPlayHistory - Historial almacenado en localStorage * @returns {Promise<{success: boolean, syncedCount: number}>} - Resultado de la sincronización */ export async function syncPlayHistoryToDB(localPlayHistory) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return { success: false, syncedCount: 0, error: 'Usuario no autenticado' }; } // Verificar si hay historial para sincronizar if (!localPlayHistory || Object.keys(localPlayHistory).length === 0) { return { success: true, syncedCount: 0 }; } let syncedCount = 0; // Procesar cada entrada del historial for (const trackId in localPlayHistory) { try { const trackInfo = localPlayHistory[trackId]; const count = trackInfo.count || 1; // Registrar cada reproducción for (let i = 0; i < count; i++) { await db.execute( 'INSERT INTO historial_musica (usuario_id, musica_id, fecha_reproduccion) VALUES (?, ?, ?)', [userId, trackId, trackInfo.lastPlayed || new Date().toISOString()] ); syncedCount++; } } catch (itemError) { console.error(`Error al sincronizar historial ${trackId}:`, itemError); } } return { success: true, syncedCount, message: `${syncedCount} reproducciones sincronizadas correctamente` }; } catch (error) { console.error('Error al sincronizar historial:', error); return { success: false, syncedCount: 0, error: 'Error al sincronizar historial con la base de datos' }; } } /** * Obtiene el historial de descargas del usuario * @param {number} limit - Límite de elementos * @returns {Promise} - Historial de descargas */ export async function getUserDownloads(limit = 20) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return []; } // Consultar descargas relacionadas con el historial del usuario const [rows] = await db.execute(` SELECT h.musica_id, h.fecha_reproduccion, m.titulo, m.artista, m.album, m.url, d.contador FROM historial_musica h LEFT JOIN musicas m ON h.musica_id = m.id LEFT JOIN descargas d ON h.musica_id = d.musica_id WHERE h.usuario_id = ? ORDER BY h.fecha_reproduccion DESC LIMIT ? `, [userId, limit]); return rows; } catch (error) { console.error('Error al obtener descargas:', error); return []; } } /** * Obtiene las pistas más descargadas * @param {number} limit - Límite de elementos * @returns {Promise} - Pistas más descargadas */ export async function getMostDownloadedTracks(limit = 10) { try { // Consultar top de descargas const [rows] = await db.execute(` SELECT d.musica_id, d.contador, d.fecha_descarga, m.titulo, m.artista, m.album, m.url FROM descargas d LEFT JOIN musicas m ON d.musica_id = m.id ORDER BY d.contador DESC LIMIT ? `, [limit]); return rows; } catch (error) { console.error('Error al obtener top de descargas:', error); return []; } } /** * Guarda una canción faltante en la base de datos * @param {Object} track - Datos de la canción * @returns {Promise} - Resultado de la operación */ export async function saveMissingTrack(track) { if (!track || !track.id) { return { success: false, error: 'Datos de pista inválidos' }; } try { // Verificar si la canción ya existe const [existingRows] = await db.execute( 'SELECT id FROM musicas WHERE id = ? LIMIT 1', [track.id] ); if (existingRows.length > 0) { // Si existe pero sin datos completos, intentar actualizarla const [trackData] = await db.execute( 'SELECT titulo, artista, album FROM musicas WHERE id = ? LIMIT 1', [track.id] ); if (trackData.length > 0 && (trackData[0].titulo === 'Sin título' || trackData[0].artista === 'Artista desconocido')) { // Intentar obtener datos actualizados const trackDetails = await getTrackDetails(track.id); if (trackDetails && trackDetails.title) { await db.execute(` UPDATE musicas SET titulo = ?, artista = ?, album = ? WHERE id = ? `, [ trackDetails.title, trackDetails.artist?.name || 'Artista desconocido', trackDetails.album?.title || 'Álbum desconocido', track.id ]); console.log(`Pista ${track.id} actualizada con datos de Qobuz`); } } return { success: true, message: 'La pista ya existe en la base de datos', trackId: existingRows[0].id }; } // Si no existe, intentar obtener detalles completos let title = track.title || 'Sin título'; let artist = track.artist?.name || 'Artista desconocido'; let album = track.album?.title || 'Álbum desconocido'; // Intentar obtener datos más completos desde Qobuz try { const trackDetails = await getTrackDetails(track.id); if (trackDetails && trackDetails.title) { title = trackDetails.title; artist = trackDetails.artist?.name || artist; album = trackDetails.album?.title || album; console.log(`Detalles obtenidos desde Qobuz para pista ${track.id}`); } } catch (err) { console.warn(`No se pudieron obtener detalles para pista ${track.id}:`, err); } // Preparar URL de streaming si está disponible let streamUrl = ''; if (track.stream_url) { streamUrl = track.stream_url; } else { try { streamUrl = await fetchTrackStreamingUrl(track.id) || ''; } catch (err) { console.warn(`No se pudo obtener URL para pista ${track.id}:`, err); } } // Insertar nueva canción con los datos mejorados await db.execute(` INSERT INTO musicas (id, titulo, artista, album, duracion, url) VALUES (?, ?, ?, ?, ?, ?) `, [ track.id, title, artist, album, track.duration || 0, streamUrl ]); console.log(`Nueva pista ${track.id} insertada con título: ${title}, artista: ${artist}`); return { success: true, message: 'Pista guardada correctamente', trackId: track.id }; } catch (error) { console.error('Error al guardar pista:', error); return { success: false, error: 'Error al guardar pista en la base de datos' }; } } /** * Verifica el estado de la conexión y reinicializa si es necesario * @returns {Promise} - Estado de la conexión */ export async function checkConnection() { try { // Hacer una petición simple para verificar conexión const response = await qobuzAPI.get('status', { params: { app_id: QOBUZ_APP_ID }, timeout: 3000 }); if (response.status === 200) { return { connected: true, status: response.data }; } throw new Error('Estado de conexión desconocido'); } catch (error) { console.warn('⚠️ Reinicializando conexión API:', error.message); // Reinicializar instancia de API qobuzAPI = createAxiosInstance(); return { connected: false, error: error.message, reinitialized: true }; } } /** * Verifica si una pista está en favoritos * @param {string|number} trackId - ID de la pista * @returns {Promise} - true si está en favoritos */ export async function isTrackFavorite(trackId) { try { const { isLoggedIn, userId } = await isUserAuthenticated(); if (!isLoggedIn || !userId) { return false; } // Verificar en base de datos const [rows] = await db.execute( 'SELECT id FROM favoritos WHERE usuario_id = ? AND musica_id = ? LIMIT 1', [userId, trackId] ); return rows.length > 0; } catch (error) { console.error('Error al verificar favorito:', error); return false; } } // Exportar funciones export { preloadTrackStreams, preloadAdjacentPages, checkConnection, isUserAuthenticated, addToFavorites, removeFromFavorites, registerDownload, registerPlayHistorial, getUserFavorites, isTrackFavorite, getUserPlayHistory, getUserDownloads, syncFavoritesToDB, syncDownloadsToDB, saveMissingTrack, getMostDownloadedTracks, getPlayHistoryByDay, getMostPlayedTracks, clearPlayHistory, syncPlayHistoryToDB, removeTrackFromHistory };