diff --git a/bun.lock b/bun.lock index 1611995..d07d03d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,12 +4,15 @@ "": { "name": "shark", "dependencies": { + "@noble/ciphers": "^2.0.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-fs": "~2", "@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-store": "~2", + "blowfish-node": "^1.1.4", + "browser-id3-writer": "^6.3.1", "music-metadata": "^11.9.0", }, "devDependencies": { @@ -89,6 +92,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@noble/ciphers": ["@noble/ciphers@2.0.1", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.3", "", { "os": "android", "cpu": "arm" }, "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw=="], @@ -197,6 +202,10 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "blowfish-node": ["blowfish-node@1.1.4", "", {}, "sha512-Iahpxc/cutT0M0tgwV5goklB+EzDuiYLgwJg050AmUG2jSIOpViWMLdnRgBxzZuNfswAgHSUiIdvmNdgL2v6DA=="], + + "browser-id3-writer": ["browser-id3-writer@6.3.1", "", {}, "sha512-sRA4Uq9Q3NsmXiVpLvIDxzomtgCdbw6SY85A6fw7dUQGRVoOBg1/buFv6spPhYiSo6FlVtN5OJQTvvhbmfx9rQ=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], diff --git a/package.json b/package.json index 620e15e..e61600f 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ }, "license": "MIT", "dependencies": { + "@noble/ciphers": "^2.0.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-fs": "~2", "@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-store": "~2", + "blowfish-node": "^1.1.4", + "browser-id3-writer": "^6.3.1", "music-metadata": "^11.9.0" }, "devDependencies": { diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a7b7e32..17a1d5b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -41,6 +41,15 @@ }, { "url": "http://*.deezer.com/**" + }, + { + "url": "https://media.deezer.com/**" + }, + { + "url": "https://*.dzcdn.net/**" + }, + { + "url": "http://*.dzcdn.net/**" } ] } diff --git a/src/lib/services/deezer.ts b/src/lib/services/deezer.ts index 46e1f00..35c7cde 100644 --- a/src/lib/services/deezer.ts +++ b/src/lib/services/deezer.ts @@ -38,6 +38,7 @@ export class DeezerAPI { private httpHeaders: Record; private arl: string | null = null; private apiToken: string | null = null; + private cookies: Map = 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 { + private async apiCall(method: string, args: any = {}, params: any = {}, retryCount: number = 0): Promise { 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 { + return this.apiCall('song.getData', { SNG_ID: trackId }); + } + + // 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: 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 { + 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 diff --git a/src/lib/services/deezer/crypto.ts b/src/lib/services/deezer/crypto.ts new file mode 100644 index 0000000..af73957 --- /dev/null +++ b/src/lib/services/deezer/crypto.ts @@ -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}`; +} diff --git a/src/lib/services/deezer/downloader.ts b/src/lib/services/deezer/downloader.ts new file mode 100644 index 0000000..30c2aa2 --- /dev/null +++ b/src/lib/services/deezer/downloader.ts @@ -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 { + // 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 { + 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 { + const paths = generateTrackPath(track, musicFolder, format, false); + const finalPath = `${paths.filepath}/${paths.filename}`; + + return await exists(finalPath); +} diff --git a/src/lib/services/deezer/paths.ts b/src/lib/services/deezer/paths.ts new file mode 100644 index 0000000..b8df987 --- /dev/null +++ b/src/lib/services/deezer/paths.ts @@ -0,0 +1,149 @@ +/** + * Path template system for Deezer downloads + * Generates file paths based on track metadata + * Hard-coded template: // - + */ + +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`; +} diff --git a/src/lib/types/deezer.ts b/src/lib/types/deezer.ts new file mode 100644 index 0000000..e5e5a78 --- /dev/null +++ b/src/lib/types/deezer.ts @@ -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; +} diff --git a/src/routes/services/deezer/+page.svelte b/src/routes/services/deezer/+page.svelte index 606688a..2737900 100644 --- a/src/routes/services/deezer/+page.svelte +++ b/src/routes/services/deezer/+page.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/deezer'; import { deezerAPI } from '$lib/services/deezer'; + import { downloadTrack } from '$lib/services/deezer/downloader'; + import { settings } from '$lib/stores/settings'; let arlInput = $state(''); let isLoading = $state(false); @@ -10,6 +12,14 @@ let testingAuth = $state(false); let authTestResult = $state<string | null>(null); + // Track download test + let trackIdInput = $state('3135556'); // Default: Daft Punk - One More Time + let isFetchingTrack = $state(false); + let isDownloading = $state(false); + let trackInfo = $state<any>(null); + let downloadStatus = $state(''); + let downloadError = $state(''); + onMount(async () => { await loadDeezerAuth(); }); @@ -73,6 +83,104 @@ testingAuth = false; } } + + async function fetchTrackInfo() { + if (!$deezerAuth.arl || !$deezerAuth.user) { + downloadError = 'Not logged in'; + return; + } + + isFetchingTrack = true; + downloadError = ''; + trackInfo = null; + + try { + deezerAPI.setArl($deezerAuth.arl); + const trackData = await deezerAPI.getTrack(trackIdInput); + console.log('Track data:', trackData); + + if (!trackData || !trackData.SNG_ID) { + throw new Error('Track not found or invalid track ID'); + } + + trackInfo = trackData; + } catch (error) { + console.error('Fetch error:', error); + downloadError = error instanceof Error ? error.message : 'Failed to fetch track'; + } finally { + isFetchingTrack = false; + } + } + + async function downloadTrackNow() { + if (!trackInfo) { + downloadError = 'Please fetch track info first'; + return; + } + + if (!$settings.musicFolder) { + downloadError = 'Please set a music folder in Settings first'; + return; + } + + isDownloading = true; + downloadStatus = 'Getting download URL...'; + downloadError = ''; + + try { + const format = $deezerAuth.user!.can_stream_lossless ? 'FLAC' : 'MP3_320'; + const downloadURL = await deezerAPI.getTrackDownloadUrl( + trackInfo.TRACK_TOKEN, + format, + $deezerAuth.user!.license_token! + ); + + if (!downloadURL) { + throw new Error('Could not get download URL'); + } + + downloadStatus = 'Downloading and decrypting...'; + console.log('Download URL:', downloadURL); + + // Build track object + const track = { + id: trackInfo.SNG_ID, + title: trackInfo.SNG_TITLE, + artist: trackInfo.ART_NAME, + artistId: trackInfo.ART_ID, + artists: [trackInfo.ART_NAME], + album: trackInfo.ALB_TITLE, + albumId: trackInfo.ALB_ID, + albumArtist: trackInfo.ART_NAME, // Simplified for test + albumArtistId: trackInfo.ART_ID, + trackNumber: trackInfo.TRACK_NUMBER || 1, + discNumber: trackInfo.DISK_NUMBER || 1, + duration: trackInfo.DURATION, + explicit: trackInfo.EXPLICIT_LYRICS === 1, + md5Origin: trackInfo.MD5_ORIGIN, + mediaVersion: trackInfo.MEDIA_VERSION, + trackToken: trackInfo.TRACK_TOKEN + }; + + // Download track + const filePath = await downloadTrack( + track, + downloadURL, + $settings.musicFolder, + format + ); + + downloadStatus = `✓ Downloaded successfully to: ${filePath}`; + console.log('Download complete:', filePath); + + } catch (error) { + console.error('Download error:', error); + downloadError = error instanceof Error ? error.message : 'Download failed'; + downloadStatus = ''; + } finally { + isDownloading = false; + } + } </script> <div class="deezer-page"> @@ -177,25 +285,61 @@ </div> </section> - <!-- Test Authentication --> + <!-- Test Track Download --> <section class="window test-section"> <div class="title-bar"> - <div class="title-bar-text">Test Authentication</div> + <div class="title-bar-text">Test Track Download</div> </div> <div class="window-body"> - <p>Test if your authentication is working:</p> + <p>Download a test track to verify decryption is working:</p> - {#if authTestResult} - <div class={authTestResult.startsWith('✓') ? 'success-message' : 'error-message'}> - {authTestResult} + <div class="field-row-stacked"> + <label for="track-id">Track ID (from Deezer URL)</label> + <input + id="track-id" + type="text" + bind:value={trackIdInput} + placeholder="e.g., 3135556" + disabled={isFetchingTrack || isDownloading} + /> + <small class="help-text">Default: 3135556 (Daft Punk - One More Time)</small> + </div> + + {#if trackInfo} + <div class="track-info"> + <strong>{trackInfo.SNG_TITLE}</strong> by {trackInfo.ART_NAME} + <br> + <small>Album: {trackInfo.ALB_TITLE} • Duration: {Math.floor(trackInfo.DURATION / 60)}:{String(trackInfo.DURATION % 60).padStart(2, '0')}</small> + </div> + {/if} + + {#if downloadStatus} + <div class="success-message"> + {downloadStatus} + </div> + {/if} + + {#if downloadError} + <div class="error-message"> + ⚠ {downloadError} </div> {/if} <div class="button-row"> - <button onclick={testAuthentication} disabled={testingAuth}> - {testingAuth ? 'Testing...' : 'Test Authentication'} + <button onclick={fetchTrackInfo} disabled={isFetchingTrack || isDownloading}> + {isFetchingTrack ? 'Fetching...' : 'Fetch Track Info'} + </button> + + <button onclick={downloadTrackNow} disabled={!trackInfo || isDownloading || !$settings.musicFolder}> + {isDownloading ? 'Downloading...' : 'Download'} </button> </div> + + {#if !$settings.musicFolder} + <p class="help-text" style="margin-top: 8px;"> + ⚠ Please set a music folder in Settings before downloading. + </p> + {/if} </div> </section> {/if} @@ -239,11 +383,18 @@ min-width: 140px; } - input[type="password"] { + input[type="password"], + input[type="text"] { width: 100%; padding: 4px; } + .help-text { + color: var(--text-color, #FFFFFF); + opacity: 0.7; + font-size: 0.85em; + } + .button-row { margin-top: 12px; display: flex; @@ -300,4 +451,15 @@ .user-info { margin-bottom: 12px; } + + .track-info { + padding: 8px; + margin: 8px 0; + background-color: var(--button-shadow, #2a2a2a); + border: 1px solid var(--button-highlight, #606060); + } + + .track-info strong { + font-weight: bold; + } </style>