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",
|
"name": "shark",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^2.0.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "~2",
|
"@tauri-apps/plugin-fs": "~2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
|
"blowfish-node": "^1.1.4",
|
||||||
|
"browser-id3-writer": "^6.3.1",
|
||||||
"music-metadata": "^11.9.0",
|
"music-metadata": "^11.9.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|||||||
@@ -13,12 +13,15 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^2.0.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "~2",
|
"@tauri-apps/plugin-fs": "~2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
|
"blowfish-node": "^1.1.4",
|
||||||
|
"browser-id3-writer": "^6.3.1",
|
||||||
"music-metadata": "^11.9.0"
|
"music-metadata": "^11.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -41,6 +41,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "http://*.deezer.com/**"
|
"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 httpHeaders: Record<string, string>;
|
||||||
private arl: string | null = null;
|
private arl: string | null = null;
|
||||||
private apiToken: string | null = null;
|
private apiToken: string | null = null;
|
||||||
|
private cookies: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.httpHeaders = {
|
this.httpHeaders = {
|
||||||
@@ -51,6 +52,28 @@ export class DeezerAPI {
|
|||||||
// Set ARL cookie for authentication
|
// Set ARL cookie for authentication
|
||||||
setArl(arl: string): void {
|
setArl(arl: string): void {
|
||||||
this.arl = arl.trim();
|
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
|
// Get API token from getUserData
|
||||||
@@ -60,7 +83,7 @@ export class DeezerAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Call Deezer GW API
|
// 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') {
|
if (!this.apiToken && method !== 'deezer.getUserData') {
|
||||||
this.apiToken = await this.getToken();
|
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 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 {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...this.httpHeaders,
|
...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();
|
const resultJson: GWAPIResponse = await response.json();
|
||||||
|
console.log(`[DEBUG] Response JSON for ${method}:`, resultJson);
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors - check if error exists and is not empty
|
||||||
if (resultJson.error && (Array.isArray(resultJson.error) ? resultJson.error.length : Object.keys(resultJson.error).length)) {
|
const hasError = resultJson.error && (
|
||||||
|
Array.isArray(resultJson.error)
|
||||||
|
? resultJson.error.length > 0
|
||||||
|
: Object.keys(resultJson.error).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
const errorStr = JSON.stringify(resultJson.error);
|
const errorStr = JSON.stringify(resultJson.error);
|
||||||
|
console.error(`[ERROR] API returned error for ${method}:`, errorStr);
|
||||||
|
|
||||||
// Handle invalid token - retry with new token
|
// Handle invalid token - retry with new token (max 2 retries)
|
||||||
if (errorStr.includes('invalid api token') || errorStr.includes('Invalid CSRF token')) {
|
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();
|
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}`);
|
throw new Error(`Deezer API Error: ${errorStr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set token from getUserData response
|
// Set token from getUserData response (always update it)
|
||||||
if (!this.apiToken && method === 'deezer.getUserData') {
|
if (method === 'deezer.getUserData' && resultJson.results?.checkForm) {
|
||||||
this.apiToken = resultJson.results.checkForm || null;
|
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;
|
return resultJson.results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ERROR] deezer.gw', method, args, error);
|
console.error('[ERROR] deezer.gw', method, args, error);
|
||||||
@@ -166,6 +227,84 @@ export class DeezerAPI {
|
|||||||
return false;
|
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
|
// 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 { onMount } from 'svelte';
|
||||||
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/deezer';
|
||||||
import { deezerAPI } from '$lib/services/deezer';
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
|
import { downloadTrack } from '$lib/services/deezer/downloader';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
|
||||||
let arlInput = $state('');
|
let arlInput = $state('');
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
@@ -10,6 +12,14 @@
|
|||||||
let testingAuth = $state(false);
|
let testingAuth = $state(false);
|
||||||
let authTestResult = $state<string | null>(null);
|
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 () => {
|
onMount(async () => {
|
||||||
await loadDeezerAuth();
|
await loadDeezerAuth();
|
||||||
});
|
});
|
||||||
@@ -73,6 +83,104 @@
|
|||||||
testingAuth = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="deezer-page">
|
<div class="deezer-page">
|
||||||
@@ -177,25 +285,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Test Authentication -->
|
<!-- Test Track Download -->
|
||||||
<section class="window test-section">
|
<section class="window test-section">
|
||||||
<div class="title-bar">
|
<div class="title-bar">
|
||||||
<div class="title-bar-text">Test Authentication</div>
|
<div class="title-bar-text">Test Track Download</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="window-body">
|
<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="field-row-stacked">
|
||||||
<div class={authTestResult.startsWith('✓') ? 'success-message' : 'error-message'}>
|
<label for="track-id">Track ID (from Deezer URL)</label>
|
||||||
{authTestResult}
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button onclick={testAuthentication} disabled={testingAuth}>
|
<button onclick={fetchTrackInfo} disabled={isFetchingTrack || isDownloading}>
|
||||||
{testingAuth ? 'Testing...' : 'Test Authentication'}
|
{isFetchingTrack ? 'Fetching...' : 'Fetch Track Info'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick={downloadTrackNow} disabled={!trackInfo || isDownloading || !$settings.musicFolder}>
|
||||||
|
{isDownloading ? 'Downloading...' : 'Download'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !$settings.musicFolder}
|
||||||
|
<p class="help-text" style="margin-top: 8px;">
|
||||||
|
⚠ Please set a music folder in Settings before downloading.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -239,11 +383,18 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="password"] {
|
input[type="password"],
|
||||||
|
input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: var(--text-color, #FFFFFF);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
.button-row {
|
.button-row {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -300,4 +451,15 @@
|
|||||||
.user-info {
|
.user-info {
|
||||||
margin-bottom: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user