feat(auth): purple app track fetch

This commit is contained in:
2025-09-30 22:38:16 -04:00
parent 48d8b4a593
commit 4ebb77f341
9 changed files with 1094 additions and 20 deletions

View File

@@ -38,6 +38,7 @@ 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 = {
@@ -51,6 +52,28 @@ export class DeezerAPI {
// 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
@@ -60,7 +83,7 @@ export class DeezerAPI {
}
// Call Deezer GW API
private async apiCall(method: string, args: any = {}, params: any = {}): Promise<any> {
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();
}
@@ -75,36 +98,74 @@ export class DeezerAPI {
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': this.arl ? `arl=${this.arl}` : ''
'Cookie': cookieHeader
},
body: JSON.stringify(args)
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
if (resultJson.error && (Array.isArray(resultJson.error) ? resultJson.error.length : Object.keys(resultJson.error).length)) {
// 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);
console.error(`[ERROR] API returned error for ${method}:`, errorStr);
// Handle invalid token - retry with new token
if (errorStr.includes('invalid api token') || errorStr.includes('Invalid CSRF token')) {
// 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();
return this.apiCall(method, args, params);
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
if (!this.apiToken && method === 'deezer.getUserData') {
this.apiToken = resultJson.results.checkForm || null;
// 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) {
console.error('[ERROR] deezer.gw', method, args, error);
@@ -166,6 +227,84 @@ export class DeezerAPI {
return false;
}
}
// Get track data
async getTrack(trackId: string): Promise<any> {
return this.apiCall('song.getData', { SNG_ID: trackId });
}
// 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: 100
});
return response.TAB?.playlists?.data || [];
} catch (error) {
console.error('Error fetching playlists:', error);
return [];
}
}
// Get track download URL
async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string): Promise<string | null> {
try {
const response = await fetch('https://media.deezer.com/v1/get_url', {
method: 'POST',
headers: {
...this.httpHeaders,
'Cookie': this.arl ? `arl=${this.arl}` : ''
},
body: JSON.stringify({
license_token: licenseToken,
media: [{
type: 'FULL',
formats: [{ cipher: 'BF_CBC_STRIPE', format }]
}],
track_tokens: [trackToken]
})
});
const result = await response.json();
if (result.data && result.data.length > 0) {
const trackData = result.data[0];
if (trackData.media && trackData.media.length > 0) {
return trackData.media[0].sources[0].url;
}
}
return null;
} catch (error) {
console.error('Error getting track URL:', error);
return null;
}
}
}
// Singleton instance

View File

@@ -0,0 +1,248 @@
/**
* Crypto utilities for Deezer track decryption
* Ported from deemix to work in browser/Tauri environment
*/
import { ecb } from '@noble/ciphers/aes.js';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils.js';
import Blowfish from 'blowfish-node';
/**
* MD5 hash implementation
*/
export function md5(str: string): string {
function rotateLeft(value: number, shift: number): number {
return (value << shift) | (value >>> (32 - shift));
}
function addUnsigned(x: number, y: number): number {
const lsw = (x & 0xFFFF) + (y & 0xFFFF);
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xFFFF);
}
function F(x: number, y: number, z: number): number { return (x & y) | ((~x) & z); }
function G(x: number, y: number, z: number): number { return (x & z) | (y & (~z)); }
function H(x: number, y: number, z: number): number { return x ^ y ^ z; }
function I(x: number, y: number, z: number): number { return y ^ (x | (~z)); }
function FF(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
a = addUnsigned(a, addUnsigned(addUnsigned(F(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function GG(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
a = addUnsigned(a, addUnsigned(addUnsigned(G(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function HH(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
a = addUnsigned(a, addUnsigned(addUnsigned(H(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function II(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
a = addUnsigned(a, addUnsigned(addUnsigned(I(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function convertToWordArray(str: string): number[] {
const wordArray: number[] = [];
for (let i = 0; i < str.length * 8; i += 8) {
wordArray[i >> 5] |= (str.charCodeAt(i / 8) & 0xFF) << (i % 32);
}
return wordArray;
}
function wordToHex(value: number): string {
let hex = '';
for (let i = 0; i <= 3; i++) {
const byte = (value >>> (i * 8)) & 0xFF;
hex += ('0' + byte.toString(16)).slice(-2);
}
return hex;
}
const x = convertToWordArray(str);
let a = 0x67452301;
let b = 0xEFCDAB89;
let c = 0x98BADCFE;
let d = 0x10325476;
const S11 = 7, S12 = 12, S13 = 17, S14 = 22;
const S21 = 5, S22 = 9, S23 = 14, S24 = 20;
const S31 = 4, S32 = 11, S33 = 16, S34 = 23;
const S41 = 6, S42 = 10, S43 = 15, S44 = 21;
x[str.length >> 2] |= 0x80 << ((str.length % 4) * 8);
x[(((str.length + 8) >>> 9) << 4) + 14] = str.length * 8;
for (let i = 0; i < x.length; i += 16) {
const aa = a, bb = b, cc = c, dd = d;
a = FF(a, b, c, d, x[i + 0], S11, 0xD76AA478);
d = FF(d, a, b, c, x[i + 1], S12, 0xE8C7B756);
c = FF(c, d, a, b, x[i + 2], S13, 0x242070DB);
b = FF(b, c, d, a, x[i + 3], S14, 0xC1BDCEEE);
a = FF(a, b, c, d, x[i + 4], S11, 0xF57C0FAF);
d = FF(d, a, b, c, x[i + 5], S12, 0x4787C62A);
c = FF(c, d, a, b, x[i + 6], S13, 0xA8304613);
b = FF(b, c, d, a, x[i + 7], S14, 0xFD469501);
a = FF(a, b, c, d, x[i + 8], S11, 0x698098D8);
d = FF(d, a, b, c, x[i + 9], S12, 0x8B44F7AF);
c = FF(c, d, a, b, x[i + 10], S13, 0xFFFF5BB1);
b = FF(b, c, d, a, x[i + 11], S14, 0x895CD7BE);
a = FF(a, b, c, d, x[i + 12], S11, 0x6B901122);
d = FF(d, a, b, c, x[i + 13], S12, 0xFD987193);
c = FF(c, d, a, b, x[i + 14], S13, 0xA679438E);
b = FF(b, c, d, a, x[i + 15], S14, 0x49B40821);
a = GG(a, b, c, d, x[i + 1], S21, 0xF61E2562);
d = GG(d, a, b, c, x[i + 6], S22, 0xC040B340);
c = GG(c, d, a, b, x[i + 11], S23, 0x265E5A51);
b = GG(b, c, d, a, x[i + 0], S24, 0xE9B6C7AA);
a = GG(a, b, c, d, x[i + 5], S21, 0xD62F105D);
d = GG(d, a, b, c, x[i + 10], S22, 0x2441453);
c = GG(c, d, a, b, x[i + 15], S23, 0xD8A1E681);
b = GG(b, c, d, a, x[i + 4], S24, 0xE7D3FBC8);
a = GG(a, b, c, d, x[i + 9], S21, 0x21E1CDE6);
d = GG(d, a, b, c, x[i + 14], S22, 0xC33707D6);
c = GG(c, d, a, b, x[i + 3], S23, 0xF4D50D87);
b = GG(b, c, d, a, x[i + 8], S24, 0x455A14ED);
a = GG(a, b, c, d, x[i + 13], S21, 0xA9E3E905);
d = GG(d, a, b, c, x[i + 2], S22, 0xFCEFA3F8);
c = GG(c, d, a, b, x[i + 7], S23, 0x676F02D9);
b = GG(b, c, d, a, x[i + 12], S24, 0x8D2A4C8A);
a = HH(a, b, c, d, x[i + 5], S31, 0xFFFA3942);
d = HH(d, a, b, c, x[i + 8], S32, 0x8771F681);
c = HH(c, d, a, b, x[i + 11], S33, 0x6D9D6122);
b = HH(b, c, d, a, x[i + 14], S34, 0xFDE5380C);
a = HH(a, b, c, d, x[i + 1], S31, 0xA4BEEA44);
d = HH(d, a, b, c, x[i + 4], S32, 0x4BDECFA9);
c = HH(c, d, a, b, x[i + 7], S33, 0xF6BB4B60);
b = HH(b, c, d, a, x[i + 10], S34, 0xBEBFBC70);
a = HH(a, b, c, d, x[i + 13], S31, 0x289B7EC6);
d = HH(d, a, b, c, x[i + 0], S32, 0xEAA127FA);
c = HH(c, d, a, b, x[i + 3], S33, 0xD4EF3085);
b = HH(b, c, d, a, x[i + 6], S34, 0x4881D05);
a = HH(a, b, c, d, x[i + 9], S31, 0xD9D4D039);
d = HH(d, a, b, c, x[i + 12], S32, 0xE6DB99E5);
c = HH(c, d, a, b, x[i + 15], S33, 0x1FA27CF8);
b = HH(b, c, d, a, x[i + 2], S34, 0xC4AC5665);
a = II(a, b, c, d, x[i + 0], S41, 0xF4292244);
d = II(d, a, b, c, x[i + 7], S42, 0x432AFF97);
c = II(c, d, a, b, x[i + 14], S43, 0xAB9423A7);
b = II(b, c, d, a, x[i + 5], S44, 0xFC93A039);
a = II(a, b, c, d, x[i + 12], S41, 0x655B59C3);
d = II(d, a, b, c, x[i + 3], S42, 0x8F0CCC92);
c = II(c, d, a, b, x[i + 10], S43, 0xFFEFF47D);
b = II(b, c, d, a, x[i + 1], S44, 0x85845DD1);
a = II(a, b, c, d, x[i + 8], S41, 0x6FA87E4F);
d = II(d, a, b, c, x[i + 15], S42, 0xFE2CE6E0);
c = II(c, d, a, b, x[i + 6], S43, 0xA3014314);
b = II(b, c, d, a, x[i + 13], S44, 0x4E0811A1);
a = II(a, b, c, d, x[i + 4], S41, 0xF7537E82);
d = II(d, a, b, c, x[i + 11], S42, 0xBD3AF235);
c = II(c, d, a, b, x[i + 2], S43, 0x2AD7D2BB);
b = II(b, c, d, a, x[i + 9], S44, 0xEB86D391);
a = addUnsigned(a, aa);
b = addUnsigned(b, bb);
c = addUnsigned(c, cc);
d = addUnsigned(d, dd);
}
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
}
/**
* AES-128-ECB encryption
*/
export function ecbEncrypt(key: string, data: string): string {
const keyBytes = utf8ToBytes(key);
const dataBytes = utf8ToBytes(data);
const cipher = ecb(keyBytes);
const encrypted = cipher.encrypt(dataBytes);
return bytesToHex(encrypted);
}
/**
* AES-128-ECB decryption
*/
export function ecbDecrypt(key: string, data: string): string {
const keyBytes = utf8ToBytes(key);
const dataHex = data.slice(0, data.lastIndexOf('.'));
const dataBytes = hexToBytes(dataHex);
const cipher = ecb(keyBytes);
const decrypted = cipher.decrypt(dataBytes);
return new TextDecoder().decode(decrypted);
}
/**
* Generate Blowfish key for track decryption
*/
export function generateBlowfishKey(trackId: string): Uint8Array {
const SECRET = 'g4el58wc0zvf9na1';
const idMd5 = md5(trackId);
const bfKey = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bfKey[i] = idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i);
}
return bfKey;
}
/**
* Decrypt a chunk using Blowfish CBC
*/
export function decryptChunk(chunk: Uint8Array, blowfishKey: Uint8Array): Uint8Array {
const iv = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]);
try {
const bf = new Blowfish(blowfishKey, Blowfish.MODE.CBC, Blowfish.PADDING.NULL);
bf.setIv(iv);
const decrypted = bf.decode(Buffer.from(chunk), Blowfish.TYPE.UINT8_ARRAY);
return new Uint8Array(decrypted);
} catch (error) {
console.error('Error decrypting chunk:', error);
return chunk; // Return original if decryption fails
}
}
/**
* Generate stream path for download URL
*/
export function generateStreamPath(sngID: string, md5: string, mediaVersion: string, format: string): string {
let urlPart = `${md5}¤${format}¤${sngID}¤${mediaVersion}`;
const md5val = md5(urlPart);
let step2 = `${md5val}¤${urlPart}¤`;
step2 += '.'.repeat(16 - (step2.length % 16));
// Encrypt with AES-128-ECB
const encrypted = ecbEncrypt('jo6aey6haid2Teih', step2);
return encrypted;
}
/**
* Generate download URL from track info
*/
export function generateStreamURL(sngID: string, md5: string, mediaVersion: string, format: string): string {
const urlPart = generateStreamPath(sngID, md5, mediaVersion, format);
return `https://cdns-proxy-${md5[0]}.dzcdn.net/api/1/${urlPart}`;
}
/**
* Generate crypted stream URL (for encrypted streams)
*/
export function generateCryptedStreamURL(sngID: string, md5: string, mediaVersion: string, format: string): string {
const urlPart = generateStreamPath(sngID, md5, mediaVersion, format);
return `https://e-cdns-proxy-${md5[0]}.dzcdn.net/mobile/1/${urlPart}`;
}

View File

@@ -0,0 +1,195 @@
/**
* Deezer track downloader with streaming and decryption
*/
import { fetch } from '@tauri-apps/plugin-http';
import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
import { generateBlowfishKey, decryptChunk } from './crypto';
import { generateTrackPath } from './paths';
import type { DeezerTrack } from '$lib/types/deezer';
export interface DownloadProgress {
downloaded: number;
total: number;
percentage: number;
}
export type ProgressCallback = (progress: DownloadProgress) => void;
/**
* Download and decrypt a single track
*/
export async function downloadTrack(
track: DeezerTrack,
downloadURL: string,
musicFolder: string,
format: string,
onProgress?: ProgressCallback
): Promise<string> {
// Generate paths
const paths = generateTrackPath(track, musicFolder, format, false);
// Ensure temp folder exists
const tempFolder = `${musicFolder}/_temp`;
try {
await mkdir(tempFolder, { recursive: true });
} catch (error) {
// Folder might already exist
}
// Ensure target folder exists
try {
await mkdir(paths.filepath, { recursive: true });
} catch (error) {
// Folder might already exist
}
// Download to temp file
console.log('Downloading track:', track.title);
console.log('Download URL:', downloadURL);
console.log('Temp path:', paths.tempPath);
try {
// Fetch the track with streaming
const response = await fetch(downloadURL, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const totalSize = parseInt(response.headers.get('content-length') || '0');
const isCrypted = downloadURL.includes('/mobile/') || downloadURL.includes('/media/');
// Get the response as array buffer
const arrayBuffer = await response.arrayBuffer();
const encryptedData = new Uint8Array(arrayBuffer);
console.log(`Downloaded ${encryptedData.length} bytes, encrypted: ${isCrypted}`);
// Decrypt if needed
let decryptedData: Uint8Array;
if (isCrypted) {
console.log('Decrypting track...');
decryptedData = await decryptTrackData(encryptedData, track.id.toString());
} else {
decryptedData = encryptedData;
}
// Write to temp file
console.log('Writing to temp file...');
await writeFile(paths.tempPath, decryptedData);
// Move to final location
const finalPath = `${paths.filepath}/${paths.filename}`;
console.log('Moving to final location:', finalPath);
// Check if file already exists
if (await exists(finalPath)) {
console.log('File already exists, removing...');
await remove(finalPath);
}
await rename(paths.tempPath, finalPath);
console.log('Download complete!');
return finalPath;
} catch (error) {
// Clean up temp file on error
try {
if (await exists(paths.tempPath)) {
await remove(paths.tempPath);
}
} catch (cleanupError) {
console.error('Error cleaning up temp file:', cleanupError);
}
throw error;
}
}
/**
* Decrypt track data using Blowfish CBC
* Deezer encrypts every 3rd chunk of 2048 bytes
*/
async function decryptTrackData(data: Uint8Array, trackId: string): Promise<Uint8Array> {
const chunkSize = 2048;
const blowfishKey = generateBlowfishKey(trackId);
const result: Uint8Array[] = [];
let offset = 0;
let chunkIndex = 0;
// Skip initial padding (null bytes before actual data)
while (offset < data.length && data[offset] === 0) {
offset++;
}
// If we found padding, check if next bytes are 'ftyp' (MP4) or 'ID3' (MP3) or 'fLaC' (FLAC)
if (offset > 0 && offset + 8 < data.length) {
const header = String.fromCharCode(...data.slice(offset + 4, offset + 8));
if (header === 'ftyp') {
// Skip the null padding
result.push(data.slice(0, offset));
} else {
// Reset if we didn't find expected header
offset = 0;
}
} else {
offset = 0;
}
while (offset < data.length) {
const remainingBytes = data.length - offset;
const currentChunkSize = Math.min(chunkSize, remainingBytes);
const chunk = data.slice(offset, offset + currentChunkSize);
// Decrypt every 3rd chunk (0, 3, 6, 9, ...)
if (chunkIndex % 3 === 0 && chunk.length === chunkSize) {
try {
const decrypted = decryptChunk(chunk, blowfishKey);
result.push(decrypted);
} catch (error) {
console.error('Error decrypting chunk:', error);
result.push(chunk); // Use original if decryption fails
}
} else {
result.push(chunk);
}
offset += currentChunkSize;
chunkIndex++;
}
// Combine all chunks
const totalLength = result.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalLength);
let position = 0;
for (const chunk of result) {
combined.set(chunk, position);
position += chunk.length;
}
return combined;
}
/**
* Check if a track file already exists
*/
export async function trackExists(
track: DeezerTrack,
musicFolder: string,
format: string
): Promise<boolean> {
const paths = generateTrackPath(track, musicFolder, format, false);
const finalPath = `${paths.filepath}/${paths.filename}`;
return await exists(finalPath);
}

View File

@@ -0,0 +1,149 @@
/**
* Path template system for Deezer downloads
* Generates file paths based on track metadata
* Hard-coded template: <albumartist>/<album>/<tracknumber> - <title>
*/
import type { DeezerTrack, TrackPath } from '$lib/types/deezer';
// Illegal characters for filenames (Windows + Unix)
const ILLEGAL_CHARS = /[<>:"\/\\|?*\x00-\x1F]/g;
// Reserved names on Windows
const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
/**
* Sanitize a string for use in filenames
*/
export function sanitizeFilename(name: string, replacement = '_'): string {
if (!name) return 'Unknown';
// Replace illegal characters
let sanitized = name.replace(ILLEGAL_CHARS, replacement);
// Remove leading/trailing dots and spaces
sanitized = sanitized.trim().replace(/^\.+|\.+$/g, '');
// Check for reserved names
if (RESERVED_NAMES.test(sanitized)) {
sanitized = `${sanitized}_`;
}
// Ensure it's not too long (max 200 chars for path components)
if (sanitized.length > 200) {
sanitized = sanitized.substring(0, 200);
}
// Ensure we don't end with a dot or space
sanitized = sanitized.replace(/[\s.]+$/, '');
return sanitized || 'Unknown';
}
/**
* Pad track number with leading zeros
*/
function padTrackNumber(num: number, total: number): string {
const digits = total.toString().length;
return num.toString().padStart(digits, '0');
}
/**
* Get file extension for format
*/
export function getExtension(format: string): string {
const extensions: Record<string, string> = {
'FLAC': '.flac',
'MP3_320': '.mp3',
'MP3_128': '.mp3',
'MP4_RA3': '.mp4',
'MP4_RA2': '.mp4',
'MP4_RA1': '.mp4',
};
return extensions[format] || '.mp3';
}
/**
* Generate filename for a track
* Format: <tracknumber> - <title><ext>
*/
export function generateFilename(track: DeezerTrack, format: string): string {
const trackNum = padTrackNumber(track.trackNumber, 99); // Assume max 99 tracks per disc
const title = sanitizeFilename(track.title);
const ext = getExtension(format);
return `${trackNum} - ${title}${ext}`;
}
/**
* Generate directory path for a track
* Format: <albumartist>/<album>/
* For multi-disc albums: <albumartist>/<album>/CD<discnumber>/
*/
export function generateDirectoryPath(track: DeezerTrack, hasMultipleDiscs: boolean = false): string {
const albumArtist = sanitizeFilename(track.albumArtist || track.artist);
const album = sanitizeFilename(track.album);
let path = `${albumArtist}/${album}`;
// Add CD folder for multi-disc albums
if (hasMultipleDiscs && track.discNumber > 1) {
path += `/CD${track.discNumber}`;
}
return path;
}
/**
* Generate complete file path for a track
*/
export function generateTrackPath(
track: DeezerTrack,
musicFolder: string,
format: string,
hasMultipleDiscs: boolean = false
): TrackPath {
const dirPath = generateDirectoryPath(track, hasMultipleDiscs);
const filename = generateFilename(track, format);
const filepath = `${musicFolder}/${dirPath}`;
const relativePath = `${dirPath}/${filename}`;
const fullPath = `${filepath}/${filename}`;
// Temp path in _temp folder
const tempFilename = `${track.id}_${Date.now()}${getExtension(format)}`;
const tempPath = `${musicFolder}/_temp/${tempFilename}`;
return {
filename,
filepath,
relativePath,
tempPath
};
}
/**
* Generate M3U8-compatible relative path from music folder
* Assumes playlists folder is sibling to music folder
*/
export function generatePlaylistRelativePath(trackPath: TrackPath): string {
// Return path relative to parent of music folder
// e.g., ../Music/<albumartist>/<album>/<track>.mp3
return `../Music/${trackPath.relativePath}`;
}
/**
* Check if two paths point to the same directory
*/
export function isSameDirectory(path1: string, path2: string): boolean {
const normalize = (p: string) => p.replace(/[\/\\]+/g, '/').toLowerCase();
return normalize(path1) === normalize(path2);
}
/**
* Ensure _temp folder exists
*/
export function getTempFolderPath(musicFolder: string): string {
return `${musicFolder}/_temp`;
}

160
src/lib/types/deezer.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* Type definitions for Deezer service
*/
// Track formats available from Deezer
export const TrackFormats = {
FLAC: 'FLAC',
MP3_320: 'MP3_320',
MP3_128: 'MP3_128',
MP4_RA3: 'MP4_RA3',
MP4_RA2: 'MP4_RA2',
MP4_RA1: 'MP4_RA1',
} as const;
export type TrackFormat = typeof TrackFormats[keyof typeof TrackFormats];
// Playlist from Deezer API
export interface DeezerPlaylist {
id: string;
title: string;
description?: string;
creator: {
id: string;
name: string;
};
nb_tracks: number;
duration: number;
public: boolean;
picture_small?: string;
picture_medium?: string;
picture_big?: string;
picture_xl?: string;
creation_date?: string;
}
// Track from Deezer API/GW
export interface DeezerTrack {
id: number;
title: string;
duration: number;
trackNumber: number;
discNumber: number;
explicit: boolean;
isrc?: string;
// Artist info
artist: string;
artistId: number;
artists: string[];
// Album info
album: string;
albumId: number;
albumArtist: string; // Important: different from artist!
albumArtistId: number;
// Metadata for download
md5Origin?: string;
mediaVersion?: number;
trackToken?: string;
// Quality info
filesizes?: {
FLAC?: number;
MP3_320?: number;
MP3_128?: number;
MP4_RA3?: number;
MP4_RA2?: number;
MP4_RA1?: number;
};
// Additional metadata
bpm?: number;
gain?: number;
copyright?: string;
releaseDate?: string;
genre?: string[];
contributors?: DeezerContributor[];
}
// Contributor information
export interface DeezerContributor {
id: number;
name: string;
role: string;
}
// Download task
export interface DownloadTask {
id: string;
type: 'playlist' | 'album' | 'track';
title: string;
status: 'queued' | 'downloading' | 'completed' | 'failed' | 'paused';
progress: number;
totalTracks: number;
completedTracks: number;
failedTracks: number;
currentTrack?: {
title: string;
artist: string;
progress: number;
};
error?: string;
}
// Download settings
export interface DeezerDownloadSettings {
format: TrackFormat;
overwriteExisting: boolean;
createM3U8: boolean;
saveArtwork: boolean;
concurrentDownloads: number;
}
// Path info for downloaded track
export interface TrackPath {
filename: string;
filepath: string;
relativePath: string; // Relative to music folder
tempPath: string; // Path in _temp folder
}
// Track with download info
export interface DownloadableTrack extends DeezerTrack {
downloadURL?: string;
format: TrackFormat;
extension: string;
path: TrackPath;
}
// Album information
export interface DeezerAlbum {
id: number;
title: string;
artist: string;
artistId: number;
albumArtist?: string;
albumArtistId?: number;
trackTotal: number;
discTotal: number;
releaseDate?: string;
genre?: string[];
label?: string;
upc?: string;
explicit: boolean;
cover?: string;
coverXl?: string;
tracks?: DeezerTrack[];
}
// Download queue item
export interface QueueItem {
id: string;
type: 'playlist' | 'album' | 'track';
title: string;
artist: string;
itemId: string;
format: TrackFormat;
addedAt: number;
}