360 lines
14 KiB
JavaScript
360 lines
14 KiB
JavaScript
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;
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
} |