const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); const url = require('url'); const { spawn } = require('child_process'); const DATA_DIR = process.env.DATA_DIR || './data'; const MUSIC_DIR = process.env.MUSIC_DIR || './music'; const PORT = process.env.PORT || 3001; // Ensure directories exist if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } if (!fs.existsSync(MUSIC_DIR)) { fs.mkdirSync(MUSIC_DIR, { recursive: true }); } const server = http.createServer((req, res) => { // CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } const parsedUrl = url.parse(req.url, true); const pathname = parsedUrl.pathname; // --- Music API Proxy --- // Proxy requests to music-dl.sayqz.com to avoid CORS issues // if (pathname === '/music-api' || pathname === '/music-api/') { // const queryString = parsedUrl.search || ''; // const targetUrl = `https://music-dl.sayqz.com/api/${queryString}`; // proxyRequest(targetUrl, req, res); // return; // } // Path format: /kv/:key?token=... const match = pathname.match(/^\/kv\/([a-zA-Z0-9_-]+)$/); if (!match) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid path' })); return; } const key = match[1]; const token = parsedUrl.query.token; if (!token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Token required' })); return; } // Simple file-based storage: data/{token}_{key}.json // Using token in filename ensures isolation between users const safeToken = token.replace(/[^a-zA-Z0-9]/g, ''); const filePath = path.join(DATA_DIR, `${safeToken}_${key}.json`); if (req.method === 'GET') { if (fs.existsSync(filePath)) { const data = fs.readFileSync(filePath); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(data); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(null)); // Key not found returns null } } else if (req.method === 'POST') { let body = ''; req.on('data', chunk => body += chunk.toString()); req.on('end', () => { try { // Validate JSON const data = JSON.parse(body); fs.writeFileSync(filePath, body); // Try to handle background download if data looks like a song list // Execute in background tryDownloadSongs(data).catch(err => console.error('Download Manager Error:', err)); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (e) { console.error(e); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON' })); } }); } else { res.writeHead(405); res.end(); } }); server.listen(PORT, () => { console.log(`Sync Server running on port ${PORT}`); console.log(`Data directory: ${DATA_DIR}`); console.log(`Music directory: ${MUSIC_DIR}`); }); // --- Download Manager --- async function tryDownloadSongs(data) { // Detect if data is a song list // Must be an array, and items must have id, name, and (source or platform) if (!Array.isArray(data) || data.length === 0) return; const isSongList = data.every(item => item && typeof item === 'object' && item.id && item.name && (item.source || item.platform) ); if (!isSongList) return; console.log(`[Sync] Detected song list. Starting background processing for ${data.length} songs...`); for (const song of data) { try { await processSong(song); } catch (e) { console.error(`[Sync] Error processing ${song.name}:`, e.message); } } } function processSong(song) { return new Promise((resolve) => { // Sanitize filename with proper encoding handling const sanitizeFilename = (str) => { if (!str) return 'unknown'; // Normalize Unicode (NFC) to ensure consistent encoding const normalized = str.normalize('NFC'); // Replace Windows illegal characters and control characters return normalized .replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, '_') .trim() .substring(0, 200); // Limit length to avoid path too long errors }; const safeName = sanitizeFilename(song.name); const safeArtist = sanitizeFilename(song.artist); const source = song.platform || song.source; // Filename: Artist - Name [source_id] const baseName = `${safeArtist} - ${safeName} [${source}_${song.id}]`; // Check if file exists (fuzzy match for extension) try { const files = fs.readdirSync(MUSIC_DIR); const exists = files.some(f => { // 1. Exact Match: Artist - Name [source_id].ext if (f.startsWith(baseName)) return true; // 2. Simple Format: Artist - Name.ext if (f.startsWith(`${safeArtist} - ${safeName}.`)) return true; // 3. Simple Format w/o Artist: Name.ext if (f.startsWith(`${safeName}.`)) return true; // 4. Match prefix ignoring ID: Artist - Name [ if (f.startsWith(`${safeArtist} - ${safeName} [`)) return true; return false; }); if (exists) { // console.log(`[Sync] Skipped (Exists): ${song.name}`); resolve(); return; } } catch (e) { console.error('Error reading music dir:', e); } // Get URL (prefer FLAC) const apiUrl = `https://music-dl.sayqz.com/api/?source=${source}&id=${song.id}&type=url&br=flac`; https.get(apiUrl, (res) => { const handleDownload = (url) => { let ext = 'mp3'; if (url.includes('.flac')) ext = 'flac'; else if (url.includes('.m4a')) ext = 'm4a'; else if (url.includes('.ogg')) ext = 'ogg'; else if (url.includes('.wav')) ext = 'wav'; const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`); const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`); downloadFile(url, tempFile) .then(async () => { try { // Try to download cover image let coverPath = null; try { const coverUrl = `https://music-dl.sayqz.com/api/?source=${source}&id=${song.id}&type=pic`; coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`); await downloadFile(coverUrl, coverPath); } catch (e) { console.warn(`[Sync] Failed to download cover for ${song.name}:`, e.message); } // Write Metadata (Title, Artist, Album, Cover) await writeMetadata(tempFile, finalFile, { title: song.name, artist: song.artist, album: song.album || song.name, // Fallback to song name if album is missing cover: coverPath }); console.log(`[Sync] Downloaded & Tagged: ${baseName}.${ext}`); // Cleanup temp files if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); if (coverPath && fs.existsSync(coverPath)) fs.unlinkSync(coverPath); } catch (err) { console.error(`[Sync] Metadata Error for ${song.name}:`, err.message); // Fallback: Just rename temp to final if (!fs.existsSync(finalFile)) { fs.renameSync(tempFile, finalFile); } } resolve(); }) .catch(err => { console.error(`[Sync] Download failed for ${song.name}:`, err.message); if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); resolve(); }); }; // Handle 302 Redirect (Standard API behavior) if (res.statusCode === 302 && res.headers.location) { res.resume(); handleDownload(res.headers.location); return; } if (res.statusCode !== 200) { console.error(`[Sync] API Request Failed for ${song.name}: Status ${res.statusCode}`); res.resume(); // Consume response data to free up memory resolve(); return; } let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const apiRes = JSON.parse(data); const url = apiRes.url; if (!url) { console.log(`[Sync] No URL found for ${song.name}`); resolve(); return; } handleDownload(url); } catch (e) { console.error('[Sync] API Parse Error:', e); console.error('[Sync] Raw Response:', data); resolve(); } }); }).on('error', (e) => { console.error(`[Sync] API Request Error: ${e.message}`); resolve(); }); }); } function downloadFile(url, dest) { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http; protocol.get(url, (res) => { // Handle Redirects if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { downloadFile(res.headers.location, dest).then(resolve).catch(reject); return; } if (res.statusCode !== 200) { reject(new Error(`Status code ${res.statusCode}`)); return; } const file = fs.createWriteStream(dest); res.pipe(file); file.on('finish', () => { file.close(() => resolve()); }); file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); }).on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); }); } // --- Music API Proxy Function --- function proxyRequest(targetUrl, req, res) { const parsedTarget = url.parse(targetUrl, true); const requestType = parsedTarget.query && parsedTarget.query.type; const isPicRequest = requestType === 'pic'; const isUrlRequest = requestType === 'url'; const maxRedirects = 5; const buildHeaders = () => { const headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': '*/*', 'Accept-Encoding': 'identity' }; if (req.headers.range) headers['Range'] = req.headers.range; if (req.headers['if-range']) headers['If-Range'] = req.headers['if-range']; return headers; }; const requestStream = (nextUrl, redirectCount = 0) => { if (redirectCount > maxRedirects) { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Too many redirects' })); return; } const parsedNext = url.parse(nextUrl); const isHttps = parsedNext.protocol === 'https:'; const requestModule = isHttps ? https : http; const options = { hostname: parsedNext.hostname, port: parsedNext.port || (isHttps ? 443 : 80), path: parsedNext.path, method: req.method, headers: buildHeaders() }; const proxyReq = requestModule.request(options, (proxyRes) => { if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) { const resolved = url.resolve(nextUrl, proxyRes.headers.location); proxyRes.resume(); requestStream(resolved, redirectCount + 1); return; } const headers = { ...proxyRes.headers }; delete headers['content-encoding']; delete headers['transfer-encoding']; res.writeHead(proxyRes.statusCode || 200, headers); proxyRes.pipe(res); }); proxyReq.on('error', (e) => { console.error('[Proxy] Stream error:', e.message); res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Proxy stream failed', message: e.message })); }); if (req.method === 'POST') { req.pipe(proxyReq); } else { proxyReq.end(); } }; const options = { hostname: parsedTarget.hostname, port: 443, path: parsedTarget.path, method: req.method, headers: buildHeaders() }; if (isUrlRequest) { const proxyReq = https.request(options, (proxyRes) => { if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) { const resolved = url.resolve(targetUrl, proxyRes.headers.location); proxyRes.resume(); requestStream(resolved, 1); return; } const contentType = proxyRes.headers['content-type'] || ''; const shouldInspectBody = contentType.includes('application/json') || contentType.startsWith('text/'); if (shouldInspectBody) { let body = ''; proxyRes.setEncoding('utf8'); proxyRes.on('data', chunk => body += chunk); proxyRes.on('end', () => { try { const parsedBody = JSON.parse(body); const extractUrl = (payload) => { if (!payload || typeof payload !== 'object') return null; if (typeof payload.url === 'string') return payload.url; if (typeof payload.data === 'string') return payload.data; if (payload.data && typeof payload.data === 'object') { if (typeof payload.data.url === 'string') return payload.data.url; if (typeof payload.data.link === 'string') return payload.data.link; if (payload.data.data && typeof payload.data.data.url === 'string') return payload.data.data.url; if (Array.isArray(payload.data) && payload.data[0] && typeof payload.data[0].url === 'string') { return payload.data[0].url; } } if (payload.result && typeof payload.result.url === 'string') return payload.result.url; return null; }; const resolvedUrl = extractUrl(parsedBody); if (resolvedUrl) { requestStream(url.resolve(targetUrl, resolvedUrl), 1); return; } } catch (e) { // fall through to return original body } res.writeHead(proxyRes.statusCode || 200, { 'Content-Type': proxyRes.headers['content-type'] || 'application/json' }); res.end(body); }); return; } const headers = { ...proxyRes.headers }; delete headers['content-encoding']; delete headers['transfer-encoding']; res.writeHead(proxyRes.statusCode || 200, headers); proxyRes.pipe(res); }); proxyReq.on('error', (e) => { console.error('[Proxy] Request error:', e.message); res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Proxy request failed', message: e.message })); }); if (req.method === 'POST') { req.pipe(proxyReq); } else { proxyReq.end(); } return; } const proxyReq = https.request(options, (proxyRes) => { const passThrough = () => { const headers = { ...proxyRes.headers }; delete headers['content-encoding']; delete headers['transfer-encoding']; res.writeHead(proxyRes.statusCode, headers); proxyRes.pipe(res); }; // pic endpoint now returns JSON, turn it into a redirect for if (isPicRequest) { if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) { res.writeHead(302, { Location: proxyRes.headers.location }); proxyRes.resume(); res.end(); return; } const contentType = proxyRes.headers['content-type'] || ''; if (contentType.startsWith('image/')) { passThrough(); return; } let body = ''; proxyRes.setEncoding('utf8'); proxyRes.on('data', chunk => body += chunk); proxyRes.on('end', () => { try { const parsedBody = JSON.parse(body); if (parsedBody && parsedBody.url) { res.writeHead(302, { Location: parsedBody.url }); res.end(); return; } } catch (e) { // fall through } res.writeHead(proxyRes.statusCode || 200, { 'Content-Type': proxyRes.headers['content-type'] || 'application/json' }); res.end(body); }); return; } // Handle redirects if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) { // For redirects, return the redirect URL to client res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ url: proxyRes.headers.location })); return; } passThrough(); }); proxyReq.on('error', (e) => { console.error('[Proxy] Request error:', e.message); res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Proxy request failed', message: e.message })); }); // Forward request body for POST requests if (req.method === 'POST') { req.pipe(proxyReq); } else { proxyReq.end(); } } function writeMetadata(inputPath, outputPath, metadata) { return new Promise((resolve, reject) => { const args = ['-i', inputPath]; // Add cover input if available if (metadata.cover) { args.push('-i', metadata.cover); args.push('-map', '0:0'); // Map audio from first input args.push('-map', '1:0'); // Map image from second input args.push('-c:v', 'mjpeg'); // Convert cover to jpeg // ID3v2 metadata for MP3 (cover art) if (outputPath.endsWith('.mp3')) { args.push('-id3v2_version', '3'); args.push('-metadata:s:v', 'title="Album cover"'); args.push('-metadata:s:v', 'comment="Cover (front)"'); } else if (outputPath.endsWith('.flac')) { args.push('-disposition:v', 'attached_pic'); } } else { args.push('-c', 'copy'); } // Add metadata tags args.push( '-metadata', `title=${metadata.title}`, '-metadata', `artist=${metadata.artist}`, '-metadata', `album=${metadata.album}` ); // Required for FLAC + Cover to work properly without re-encoding audio if (outputPath.endsWith('.flac') && metadata.cover) { args.push('-c:a', 'copy'); } else if (outputPath.endsWith('.mp3') && metadata.cover) { args.push('-c:a', 'copy'); } args.push('-y', outputPath); const ffmpeg = spawn('ffmpeg', args); // Capture stderr for debugging let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { if (code === 0) resolve(); else { console.error(`FFmpeg Error Output: ${stderr}`); reject(new Error(`FFmpeg exited with code ${code}`)); } }); ffmpeg.on('error', (err) => { reject(err); }); }); }