mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
473 lines
14 KiB
TypeScript
473 lines
14 KiB
TypeScript
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<string, string>;
|
|
private arl: string | null = null;
|
|
private apiToken: string | null = null;
|
|
private cookies: Map<string, string> = 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<string> {
|
|
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<any> {
|
|
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 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<DeezerUserData> {
|
|
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<boolean> {
|
|
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<any> {
|
|
return this.apiCall('song.getData', { SNG_ID: trackId });
|
|
}
|
|
|
|
// Search tracks using public API (no authentication required)
|
|
async searchTracks(query: string, limit: number = 25): Promise<any> {
|
|
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<any> {
|
|
return this.apiCall('deezer.pagePlaylist', {
|
|
PLAYLIST_ID: playlistId,
|
|
lang: 'en',
|
|
header: true,
|
|
tab: 0
|
|
});
|
|
}
|
|
|
|
// Get playlist tracks
|
|
async getPlaylistTracks(playlistId: string): Promise<any[]> {
|
|
const response = await this.apiCall('playlist.getSongs', {
|
|
PLAYLIST_ID: playlistId,
|
|
nb: -1
|
|
});
|
|
return response.data || [];
|
|
}
|
|
|
|
// Get user playlists
|
|
async getUserPlaylists(): Promise<any[]> {
|
|
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<any[]> {
|
|
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<any[]> {
|
|
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<any[]> {
|
|
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<any> {
|
|
return this.apiCall('album.getData', { alb_id: albumId });
|
|
}
|
|
|
|
// Get track lyrics
|
|
async getLyrics(trackId: string): Promise<any> {
|
|
return this.apiCall('song.getLyrics', { sng_id: trackId });
|
|
}
|
|
|
|
// Get track download URL
|
|
async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise<string | null> {
|
|
console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken });
|
|
|
|
try {
|
|
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];
|
|
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 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();
|