Files
Mymusic3/sync-server/server.js
史悦 a244347999 Modified processSong to check for res.statusCode === 302.
If a redirect is encountered, the Location header is extracted and treated as the direct download URL for the music file.
This aligns the code with the API behavior which redirects to the actual file location instead of returning a JSON response.
2026-01-06 16:22:48 +08:00

249 lines
8.8 KiB
JavaScript

const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const url = require('url');
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) => {
// Basic sanitization
const safeName = (song.name || 'unknown').replace(/[\\/:*?"<>|]/g, '_');
const safeArtist = (song.artist || 'unknown').replace(/[\\/:*?"<>|]/g, '_');
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);
// Check for standard format OR manual rename (Song Name.ext)
const exists = files.some(f => {
// 1. Standard format: Artist - Name [source_id].ext
if (f.startsWith(baseName)) return true;
// 2. Manual rename: Name.ext (ignoring extension)
// We check if the file starts with the song name followed by a dot
if (f.startsWith(`${safeName}.`)) return true;
return false;
});
if (exists) {
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 dest = path.join(MUSIC_DIR, `${baseName}.${ext}`);
downloadFile(url, dest)
.then(() => {
console.log(`[Sync] Downloaded: ${baseName}.${ext}`);
resolve();
})
.catch(err => {
console.error(`[Sync] Download failed for ${song.name}:`, err.message);
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);
});
});
}