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

@@ -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=="],

View File

@@ -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": {

View File

@@ -41,6 +41,15 @@
},
{
"url": "http://*.deezer.com/**"
},
{
"url": "https://media.deezer.com/**"
},
{
"url": "https://*.dzcdn.net/**"
},
{
"url": "http://*.dzcdn.net/**"
}
]
}

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;
}

View File

@@ -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>