import { fetch } from '@tauri-apps/plugin-http'; import type { DeezerUser } from '$lib/stores/deezer'; // Deezer API response types interface DeezerUserData { USER: { USER_ID: number; BLOG_NAME: string; USER_PICTURE?: string; MULTI_ACCOUNT?: { ENABLED: boolean; IS_SUB_ACCOUNT: boolean; }; OPTIONS: { license_token: string; web_hq?: boolean; mobile_hq?: boolean; web_lossless?: boolean; mobile_lossless?: boolean; license_country: string; }; SETTING?: { global?: { language?: string; }; }; LOVEDTRACKS_ID?: number; }; checkForm?: string; } interface GWAPIResponse { results: DeezerUserData; error: any; } export class DeezerAPI { private httpHeaders: Record; private arl: string | null = null; private apiToken: string | null = null; private cookies: Map = new Map(); constructor() { this.httpHeaders = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36', 'Content-Type': 'application/json', 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9' }; } // Set ARL cookie for authentication setArl(arl: string): void { this.arl = arl.trim(); this.cookies.set('arl', this.arl); } // Parse and store cookies from Set-Cookie header private parseCookies(setCookieHeaders: string[]): void { for (const header of setCookieHeaders) { const parts = header.split(';')[0].split('='); if (parts.length === 2) { const [name, value] = parts; this.cookies.set(name.trim(), value.trim()); console.log(`[DEBUG] Stored cookie: ${name.trim()}`); } } } // Build cookie header from stored cookies private getCookieHeader(): string { const cookies: string[] = []; this.cookies.forEach((value, name) => { cookies.push(`${name}=${value}`); }); return cookies.join('; '); } // Get API token from getUserData private async getToken(): Promise { const userData = await this.getUserData(); return userData.checkForm || ''; } // Call Deezer GW API private async apiCall(method: string, args: any = {}, params: any = {}, retryCount: number = 0): Promise { if (!this.apiToken && method !== 'deezer.getUserData') { this.apiToken = await this.getToken(); } const searchParams = new URLSearchParams({ api_version: '1.0', api_token: method === 'deezer.getUserData' ? 'null' : (this.apiToken || 'null'), input: '3', method, ...params }); const url = `http://www.deezer.com/ajax/gw-light.php?${searchParams.toString()}`; const cookieHeader = this.getCookieHeader(); console.log(`[DEBUG] API Call: ${method}`, { url, args, cookie: cookieHeader }); try { const response = await fetch(url, { method: 'POST', headers: { ...this.httpHeaders, 'Cookie': cookieHeader }, body: JSON.stringify(args), connectTimeout: 30000 }); // Parse and store cookies from response const setCookieHeader = response.headers.get('set-cookie'); if (setCookieHeader) { // Handle multiple Set-Cookie headers const setCookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; this.parseCookies(setCookies); } console.log(`[DEBUG] Response status: ${response.status}`); console.log(`[DEBUG] Response headers:`, Object.fromEntries(response.headers.entries())); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const resultJson: GWAPIResponse = await response.json(); console.log(`[DEBUG] Response JSON for ${method}:`, resultJson); // Handle errors - check if error exists and is not empty const hasError = resultJson.error && ( Array.isArray(resultJson.error) ? resultJson.error.length > 0 : Object.keys(resultJson.error).length > 0 ); if (hasError) { const errorStr = JSON.stringify(resultJson.error); const errorObj = resultJson.error as any; console.error(`[ERROR] API returned error for ${method}:`, errorStr); // Handle fallback parameters (Deezer provides alternative parameters to retry with) if ((resultJson as any).payload?.FALLBACK) { console.log(`[DEBUG] Using FALLBACK parameters for ${method}:`, (resultJson as any).payload.FALLBACK); const fallbackArgs = { ...args, ...(resultJson as any).payload.FALLBACK }; return this.apiCall(method, fallbackArgs, params, retryCount); } // Handle rate limiting (error codes 4 and 700) - wait 5 seconds and retry if (errorObj.code && [4, 700].includes(errorObj.code)) { console.log(`[DEBUG] Rate limited (code ${errorObj.code}), waiting 5s before retry...`); await new Promise(resolve => setTimeout(resolve, 5000)); return this.apiCall(method, args, params, retryCount); } // Handle invalid token - retry with new token (max 2 retries) if ((errorStr.includes('invalid api token') || errorStr.includes('Invalid CSRF token')) && retryCount < 2) { console.log(`[DEBUG] Invalid token, fetching new token... (retry ${retryCount + 1}/2)`); this.apiToken = null; // Clear the invalid token this.apiToken = await this.getToken(); console.log('[DEBUG] New token acquired, retrying API call...'); return this.apiCall(method, args, params, retryCount + 1); } throw new Error(`Deezer API Error: ${errorStr}`); } // Set token from getUserData response (always update it) if (method === 'deezer.getUserData' && resultJson.results?.checkForm) { console.log('[DEBUG] Updating API token from getUserData'); this.apiToken = resultJson.results.checkForm; } // Validate response has results if (!resultJson.results) { console.error(`[ERROR] No results in response for ${method}:`, resultJson); throw new Error(`Invalid API response: missing results field`); } console.log(`[DEBUG] Returning results for ${method}`); return resultJson.results; } catch (error: any) { console.error('[ERROR] deezer.gw', method, args, error); // Retry on network errors (connection issues, timeouts, etc.) const networkErrors = ['ECONNABORTED', 'ECONNREFUSED', 'ECONNRESET', 'ENETRESET', 'ETIMEDOUT']; if (error.code && networkErrors.includes(error.code)) { console.log(`[DEBUG] Network error (${error.code}), waiting 2s before retry...`); await new Promise(resolve => setTimeout(resolve, 2000)); return this.apiCall(method, args, params, retryCount); } throw error; } } // Get user data async getUserData(): Promise { return this.apiCall('deezer.getUserData'); } // Login via ARL token async loginViaArl(arl: string): Promise<{ success: boolean; user?: DeezerUser; error?: string }> { try { this.setArl(arl); const userData = await this.getUserData(); // Check if user is logged in if (!userData || !userData.USER || userData.USER.USER_ID === 0) { return { success: false, error: 'Invalid ARL token or not logged in' }; } // Build user object const user: DeezerUser = { id: userData.USER.USER_ID, name: userData.USER.BLOG_NAME, picture: userData.USER.USER_PICTURE || '', license_token: userData.USER.OPTIONS.license_token, can_stream_hq: userData.USER.OPTIONS.web_hq || userData.USER.OPTIONS.mobile_hq || false, can_stream_lossless: userData.USER.OPTIONS.web_lossless || userData.USER.OPTIONS.mobile_lossless || false, country: userData.USER.OPTIONS.license_country, language: userData.USER.SETTING?.global?.language || 'en' }; return { success: true, user }; } catch (error) { console.error('Login error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; } } // Test if authentication is working async testAuth(): Promise { try { const userData = await this.getUserData(); return userData && userData.USER && userData.USER.USER_ID !== 0; } catch { return false; } } // Get track data async getTrack(trackId: string): Promise { return this.apiCall('song.getData', { SNG_ID: trackId }); } // Get track page data (includes more complete track token info) async getTrackPage(trackId: string): Promise { return this.apiCall('deezer.pageTrack', { SNG_ID: trackId }); } // Get track with fallback (tries pageTrack first, then getData) async getTrackWithFallback(trackId: string): Promise { try { const pageData = await this.getTrackPage(trackId); if (pageData && pageData.DATA) { console.log(`[DEBUG] pageTrack returned for ${trackId}:`, { FILESIZE_FLAC: pageData.DATA.FILESIZE_FLAC, FALLBACK: pageData.DATA.FALLBACK, SNG_ID: pageData.DATA.SNG_ID }); return pageData.DATA; } } catch (error) { console.log(`[DEBUG] pageTrack failed for ${trackId}, falling back to getData`); } return this.getTrack(trackId); } // Search tracks using public API (no authentication required) async searchTracks(query: string, limit: number = 25): Promise { const url = `https://api.deezer.com/search/track?q=${encodeURIComponent(query)}&limit=${limit}`; console.log('[DEBUG] Searching Deezer API:', { query, limit, url }); try { const response = await fetch(url, { method: 'GET', headers: { ...this.httpHeaders }, connectTimeout: 30000 }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); console.log('[DEBUG] Search results:', result); return result; } catch (error: any) { console.error('[ERROR] Search failed:', error); throw error; } } // Get playlist data async getPlaylist(playlistId: string): Promise { return this.apiCall('deezer.pagePlaylist', { PLAYLIST_ID: playlistId, lang: 'en', header: true, tab: 0 }); } // Get playlist tracks async getPlaylistTracks(playlistId: string): Promise { const response = await this.apiCall('playlist.getSongs', { PLAYLIST_ID: playlistId, nb: -1 }); return response.data || []; } // Get user playlists async getUserPlaylists(): Promise { try { const userData = await this.getUserData(); const userId = userData.USER.USER_ID; const response = await this.apiCall('deezer.pageProfile', { USER_ID: userId, tab: 'playlists', nb: -1 }); return response.TAB?.playlists?.data || []; } catch (error) { console.error('Error fetching playlists:', error); return []; } } // Get user albums async getUserAlbums(): Promise { try { const userData = await this.getUserData(); const userId = userData.USER.USER_ID; const response = await this.apiCall('deezer.pageProfile', { USER_ID: userId, tab: 'albums', nb: -1 }); return response.TAB?.albums?.data || []; } catch (error) { console.error('Error fetching albums:', error); return []; } } // Get user artists async getUserArtists(): Promise { try { const userData = await this.getUserData(); const userId = userData.USER.USER_ID; const response = await this.apiCall('deezer.pageProfile', { USER_ID: userId, tab: 'artists', nb: -1 }); return response.TAB?.artists?.data || []; } catch (error) { console.error('Error fetching artists:', error); return []; } } // Get user favorite tracks (uses the more reliable song.getFavoriteIds method) async getUserTracks(): Promise { try { // Get favorite track IDs const idsResponse = await this.apiCall('song.getFavoriteIds', { nb: -1, start: 0, checksum: null }); const trackIds = idsResponse.data?.map((x: any) => x.SNG_ID) || []; if (trackIds.length === 0) { console.log('[Deezer] No favorite tracks found'); return []; } console.log(`[Deezer] Found ${trackIds.length} favorite track IDs, fetching details...`); // Fetch track details in batches (Deezer API might have limits) const batchSize = 100; const tracks: any[] = []; for (let i = 0; i < trackIds.length; i += batchSize) { const batchIds = trackIds.slice(i, i + batchSize); const batchResponse = await this.apiCall('song.getListData', { SNG_IDS: batchIds }); if (batchResponse.data) { tracks.push(...batchResponse.data); } } console.log(`[Deezer] Fetched ${tracks.length} track details`); return tracks; } catch (error) { console.error('Error fetching favorite tracks:', error); return []; } } // Get album data async getAlbumData(albumId: string): Promise { return this.apiCall('album.getData', { alb_id: albumId }); } // Get track lyrics async getLyrics(trackId: string): Promise { return this.apiCall('song.getLyrics', { sng_id: trackId }); } // Get track download URL async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise<{ url: string | null; errorCode?: number; errorMessage?: string }> { console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken }); try { // Use all cookies, not just arl - sid (session ID) is also required const cookieHeader = this.getCookieHeader(); console.log('[DEBUG] Sending request to media.deezer.com with cookies:', cookieHeader); const response = await fetch('https://media.deezer.com/v1/get_url', { method: 'POST', headers: { ...this.httpHeaders, 'Cookie': cookieHeader }, body: JSON.stringify({ license_token: licenseToken, media: [{ type: 'FULL', formats: [{ cipher: 'BF_CBC_STRIPE', format }] }], track_tokens: [trackToken] }), connectTimeout: 30000 }); console.log('[DEBUG] Download URL response status:', response.status); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); console.log('[DEBUG] Download URL response:', result); if (result.data && result.data.length > 0) { const trackData = result.data[0]; // Check for errors in the response if (trackData.errors && trackData.errors.length > 0) { const error = trackData.errors[0]; console.error('[ERROR] Deezer media API error:', error); // Return error details so caller can handle alternative track fallback return { url: null, errorCode: error.code, errorMessage: error.message }; } if (trackData.media && trackData.media.length > 0) { const url = trackData.media[0].sources[0].url; console.log('[DEBUG] Got download URL:', url); return { url }; } } console.error('[ERROR] No download URL in response:', result); return { url: null }; } catch (error: any) { console.error('[ERROR] Failed to get track download URL:', error); // Retry on network errors const networkErrors = ['ECONNABORTED', 'ECONNREFUSED', 'ECONNRESET', 'ENETRESET', 'ETIMEDOUT']; if (error.code && networkErrors.includes(error.code)) { console.log(`[DEBUG] Network error (${error.code}) getting download URL, waiting 2s before retry...`); await new Promise(resolve => setTimeout(resolve, 2000)); return this.getTrackDownloadUrl(trackToken, format, licenseToken, retryCount); } throw error; } } } // Singleton instance export const deezerAPI = new DeezerAPI();