mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
feat(auth): purple app track fetch
This commit is contained in:
9
bun.lock
9
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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
},
|
||||
{
|
||||
"url": "http://*.deezer.com/**"
|
||||
},
|
||||
{
|
||||
"url": "https://media.deezer.com/**"
|
||||
},
|
||||
{
|
||||
"url": "https://*.dzcdn.net/**"
|
||||
},
|
||||
{
|
||||
"url": "http://*.dzcdn.net/**"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
248
src/lib/services/deezer/crypto.ts
Normal file
248
src/lib/services/deezer/crypto.ts
Normal 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}`;
|
||||
}
|
||||
195
src/lib/services/deezer/downloader.ts
Normal file
195
src/lib/services/deezer/downloader.ts
Normal 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);
|
||||
}
|
||||
149
src/lib/services/deezer/paths.ts
Normal file
149
src/lib/services/deezer/paths.ts
Normal 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
160
src/lib/types/deezer.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user