1074 lines
38 KiB
JavaScript
1074 lines
38 KiB
JavaScript
require('dotenv').config();
|
||
const express = require('express');
|
||
const axios = require('axios');
|
||
const https = require('https');
|
||
const http = require('http');
|
||
const cron = require('node-cron');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
// Configure Axios with retry and robust settings
|
||
const apiClient = axios.create({
|
||
timeout: 30000,
|
||
httpsAgent: new https.Agent({
|
||
keepAlive: true,
|
||
rejectUnauthorized: false // Ignore self-signed certs if any, helps with some proxy/network issues
|
||
}),
|
||
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'
|
||
}
|
||
});
|
||
|
||
// Add retry interceptor
|
||
apiClient.interceptors.response.use(null, async (error) => {
|
||
const { config, message } = error;
|
||
if (!config || !config.retry) {
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
// Retry count
|
||
config.retryCount = config.retryCount || 0;
|
||
|
||
if (config.retryCount >= config.retry) {
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
config.retryCount += 1;
|
||
console.log(`[API] Retrying request (${config.retryCount}/${config.retry}): ${config.url} - Error: ${message}`);
|
||
|
||
// Exponential backoff
|
||
const delay = new Promise(resolve => {
|
||
setTimeout(resolve, config.retryDelay || 1000);
|
||
});
|
||
|
||
await delay;
|
||
return apiClient(config);
|
||
});
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3000;
|
||
|
||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||
const MUSIC_DIR = process.env.MUSIC_DIR || '/music'; // Default to /music for Docker shared volume
|
||
const PLAYLISTS_FILE = path.join(DATA_DIR, 'playlists.json');
|
||
|
||
const NAVIDROME_URL = process.env.NAVIDROME_URL || 'http://navidrome:4533';
|
||
const NAVIDROME_USERNAME = process.env.NAVIDROME_USERNAME || 'admin';
|
||
const NAVIDROME_PASSWORD = process.env.NAVIDROME_PASSWORD || '';
|
||
const SYNC_INTERVAL = process.env.SYNC_INTERVAL || 300;
|
||
const SYNC_SERVER_URL = process.env.SYNC_SERVER_URL || 'http://sync-service:3001';
|
||
const SYNC_SERVER_TOKEN = process.env.SYNC_SERVER_TOKEN || 'default';
|
||
const TUNEHUB_API_URL = process.env.TUNEHUB_API_URL || 'https://music-dl.sayqz.com';
|
||
|
||
app.use(express.json());
|
||
app.use(express.static('public'));
|
||
|
||
if (!fs.existsSync(DATA_DIR)) {
|
||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
}
|
||
if (!fs.existsSync(MUSIC_DIR)) {
|
||
// Only try to create if we have permissions, otherwise assume it's a mounted volume
|
||
try {
|
||
fs.mkdirSync(MUSIC_DIR, { recursive: true });
|
||
} catch (e) {
|
||
console.warn(`Could not create MUSIC_DIR ${MUSIC_DIR}, assuming it exists or is mounted: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
let playlists = loadPlaylists();
|
||
let syncStatus = {};
|
||
|
||
function loadPlaylists() {
|
||
if (fs.existsSync(PLAYLISTS_FILE)) {
|
||
try {
|
||
const data = fs.readFileSync(PLAYLISTS_FILE, 'utf8');
|
||
return JSON.parse(data);
|
||
} catch (e) {
|
||
console.error('Failed to load playlists:', e);
|
||
return { playlists: [] };
|
||
}
|
||
}
|
||
return { playlists: [] };
|
||
}
|
||
|
||
function savePlaylists() {
|
||
try {
|
||
fs.writeFileSync(PLAYLISTS_FILE, JSON.stringify(playlists, null, 2));
|
||
} catch (e) {
|
||
console.error('Failed to save playlists:', e);
|
||
}
|
||
}
|
||
|
||
function extractPlaylistId(input) {
|
||
// Match ?id=123123 or &id=123123
|
||
const idParamMatch = input.match(/[?&]id=(\d+)/);
|
||
if (idParamMatch) {
|
||
return idParamMatch[1];
|
||
}
|
||
|
||
// Match /playlist/123123
|
||
const pathMatch = input.match(/\/playlist\/(\d+)/);
|
||
if (pathMatch) {
|
||
return pathMatch[1];
|
||
}
|
||
|
||
// Match pure number
|
||
const idMatch = input.match(/^\d+$/);
|
||
if (idMatch) {
|
||
return input;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function getSubsonicUrl(endpoint, params = {}) {
|
||
const authParams = {
|
||
u: NAVIDROME_USERNAME,
|
||
p: NAVIDROME_PASSWORD,
|
||
v: '1.16.0',
|
||
c: 'netease-sync',
|
||
f: 'json',
|
||
...params
|
||
};
|
||
|
||
const queryParts = [];
|
||
for (const [key, value] of Object.entries(authParams)) {
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
if (item !== undefined && item !== null && item !== '') {
|
||
queryParts.push(`${key}=${encodeURIComponent(item)}`);
|
||
}
|
||
}
|
||
} else if (value !== undefined && value !== null && value !== '') {
|
||
queryParts.push(`${key}=${encodeURIComponent(value)}`);
|
||
}
|
||
}
|
||
const queryString = queryParts.join('&');
|
||
|
||
return `${NAVIDROME_URL}/rest/${endpoint}?${queryString}`;
|
||
}
|
||
|
||
async function callSubsonicAPI(endpoint, params = {}) {
|
||
try {
|
||
const url = await getSubsonicUrl(endpoint, params);
|
||
const response = await apiClient.get(url, { retry: 3, retryDelay: 1000 });
|
||
if (response.data['subsonic-response'].status === 'ok') {
|
||
return response.data['subsonic-response'];
|
||
} else {
|
||
throw new Error(response.data['subsonic-response'].error?.message || 'Subsonic API error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Subsonic API error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function getPlaylistInfo(neteaseId) {
|
||
try {
|
||
const url = `${TUNEHUB_API_URL}/api/?source=netease&id=${neteaseId}&type=playlist`;
|
||
console.log(`[API] Fetching playlist info: ${url}`);
|
||
// Add retry for external API
|
||
const response = await apiClient.get(url, { retry: 3, retryDelay: 2000 });
|
||
|
||
if (response.data.code === 200) {
|
||
const data = response.data.data;
|
||
|
||
// Normalize data structure
|
||
// TuneHub API structure per api.md: data.list (tracks) and data.info (metadata)
|
||
|
||
// 1. Extract Tracks
|
||
if (!data.tracks) {
|
||
if (data.list) {
|
||
data.tracks = data.list;
|
||
} else if (data.playlist && data.playlist.tracks) {
|
||
// Fallback for raw Netease API structure
|
||
data.tracks = data.playlist.tracks;
|
||
}
|
||
}
|
||
|
||
// 2. Extract Metadata (Name, Cover, etc.)
|
||
// Priority: data.info (TuneHub) > data.playlist (Netease Raw) > data.result (Search/Other)
|
||
const info = data.info || data.playlist || data.result || {};
|
||
|
||
data.name = info.name || data.name || 'Unknown Playlist';
|
||
data.cover = info.pic || info.coverImgUrl || info.picUrl || data.cover || '';
|
||
data.description = info.desc || info.description || data.description || '';
|
||
|
||
console.log(`[API] Raw Info Name: ${info.name}, Final Name: ${data.name}`);
|
||
console.log(`[API] Resolved playlist info: ${data.name} (Tracks: ${data.tracks?.length || 0})`);
|
||
|
||
return data;
|
||
} else {
|
||
console.error('[API] Error response:', response.data);
|
||
throw new Error(response.data.message || 'Failed to get playlist info');
|
||
}
|
||
} catch (error) {
|
||
console.error('TuneHub API error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function triggerNavidromeScan() {
|
||
try {
|
||
console.log('[Sync] Triggering Navidrome scan...');
|
||
await callSubsonicAPI('startScan');
|
||
// Give it some time to scan
|
||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||
} catch (error) {
|
||
console.warn('[Sync] Failed to trigger Navidrome scan (might be auto-scanning):', error.message);
|
||
}
|
||
}
|
||
|
||
// --- Download Logic (Ported from sync-server) ---
|
||
|
||
function 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
|
||
}
|
||
|
||
function downloadFile(url, dest, headers = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const protocol = url.startsWith('https') ? https : http;
|
||
protocol.get(url, { headers }, (res) => {
|
||
// Handle Redirects
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
downloadFile(res.headers.location, dest, headers).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 buildDownloadHeaders(source) {
|
||
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'
|
||
};
|
||
if (source === 'netease') {
|
||
headers['Referer'] = 'https://music.163.com/';
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
function getPreferredQualities(types) {
|
||
const preference = ['flac24bit', 'flac', '320k', '128k'];
|
||
if (!Array.isArray(types) || types.length === 0) {
|
||
return ['320k', '128k'];
|
||
}
|
||
const normalized = types.map(t => t?.toString().toLowerCase()).filter(Boolean);
|
||
const available = preference.filter(q => normalized.includes(q));
|
||
return available.length > 0 ? available : ['320k', '128k'];
|
||
}
|
||
|
||
function normalizeText(str) {
|
||
return (str || '')
|
||
.toString()
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[\s\-_[\]().·()、]+/g, '');
|
||
}
|
||
|
||
function pickBestSearchResult(results, songName, songArtist) {
|
||
if (!Array.isArray(results) || results.length === 0) return null;
|
||
|
||
const targetName = normalizeText(songName);
|
||
const targetArtist = normalizeText(songArtist);
|
||
|
||
let best = null;
|
||
let bestScore = -1;
|
||
|
||
for (const item of results) {
|
||
const name = normalizeText(item.name);
|
||
const artist = normalizeText(item.artist);
|
||
let score = 0;
|
||
if (name && targetName && name === targetName) score += 100;
|
||
if (artist && targetArtist && artist === targetArtist) score += 60;
|
||
if (name && targetName && name.includes(targetName)) score += 30;
|
||
if (artist && targetArtist && artist.includes(targetArtist)) score += 20;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = item;
|
||
}
|
||
}
|
||
|
||
return best || results[0];
|
||
}
|
||
|
||
async function searchSongOnTuneHub(songName, songArtist, source) {
|
||
try {
|
||
const keyword = songArtist ? `${songName} ${songArtist}` : songName;
|
||
if (!keyword) return null;
|
||
const url = `${TUNEHUB_API_URL}/api/?source=${source}&type=search&keyword=${encodeURIComponent(keyword)}&limit=20`;
|
||
const response = await apiClient.get(url, { retry: 2, retryDelay: 1000 });
|
||
if (response.data?.code !== 200) return null;
|
||
const data = response.data.data || {};
|
||
const results = data.results || [];
|
||
return pickBestSearchResult(results, songName, songArtist);
|
||
} catch (error) {
|
||
console.warn('[Sync] Search fallback failed:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function buildDownloadCandidates(source, songId, songUrl, types) {
|
||
const candidates = [];
|
||
if (songUrl) {
|
||
candidates.push({ label: 'playlist_url', apiUrl: songUrl });
|
||
}
|
||
|
||
const qualities = getPreferredQualities(types);
|
||
for (const q of qualities) {
|
||
candidates.push({
|
||
label: `br=${q}`,
|
||
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${songId}&type=url&br=${q}`
|
||
});
|
||
}
|
||
|
||
candidates.push({
|
||
label: 'br=default',
|
||
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${songId}&type=url`
|
||
});
|
||
|
||
return candidates;
|
||
}
|
||
|
||
function formatSongLog(song, source) {
|
||
const name = song?.name || 'unknown';
|
||
const id = song?.id || 'unknown';
|
||
const src = source || song?.source || 'unknown';
|
||
return `${name} (id=${id}, source=${src})`;
|
||
}
|
||
|
||
async function tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders) {
|
||
const tried = new Set();
|
||
|
||
for (const candidate of candidates) {
|
||
if (!candidate.apiUrl || tried.has(candidate.apiUrl)) continue;
|
||
tried.add(candidate.apiUrl);
|
||
|
||
const directUrl = await resolveTuneHubAudioUrl(candidate.apiUrl);
|
||
if (!directUrl) {
|
||
console.warn(`[Sync] Failed to resolve audio url for ${formatSongLog(song, source)} (${candidate.label}) api=${candidate.apiUrl}`);
|
||
continue;
|
||
}
|
||
|
||
let ext = 'mp3';
|
||
if (directUrl.includes('.flac')) ext = 'flac';
|
||
else if (directUrl.includes('.m4a')) ext = 'm4a';
|
||
else if (directUrl.includes('.ogg')) ext = 'ogg';
|
||
else if (directUrl.includes('.wav')) ext = 'wav';
|
||
|
||
const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`);
|
||
const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`);
|
||
|
||
try {
|
||
await downloadFile(directUrl, tempFile, downloadHeaders);
|
||
|
||
try {
|
||
// Try to download cover image
|
||
let coverPath = null;
|
||
try {
|
||
const coverUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=pic`;
|
||
coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`);
|
||
await downloadFile(coverUrl, coverPath, downloadHeaders);
|
||
} 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,
|
||
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);
|
||
return true; // Downloaded
|
||
} 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);
|
||
}
|
||
return true; // Downloaded (fallback)
|
||
}
|
||
} catch (err) {
|
||
console.error(`[Sync] Download failed for ${formatSongLog(song, source)} (${candidate.label}) url=${directUrl}:`, err.message);
|
||
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function resolveTuneHubAudioUrl(apiUrl) {
|
||
try {
|
||
const response = await apiClient.get(apiUrl, {
|
||
maxRedirects: 0,
|
||
validateStatus: (status) => status >= 200 && status < 400
|
||
});
|
||
|
||
if (response.status >= 300 && response.status < 400 && response.headers?.location) {
|
||
return response.headers.location;
|
||
}
|
||
|
||
if (response.status === 200 && response.data) {
|
||
const data = response.data.data || response.data;
|
||
if (typeof data === 'object' && data.url) {
|
||
return data.url;
|
||
}
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
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);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function processSong(song) {
|
||
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}`);
|
||
return true; // Exists
|
||
}
|
||
} catch (e) {
|
||
console.error('Error reading music dir:', e);
|
||
}
|
||
|
||
const downloadHeaders = buildDownloadHeaders(source);
|
||
const candidates = buildDownloadCandidates(source, song.id, song.url, song.types);
|
||
|
||
const primaryOk = await tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders);
|
||
if (primaryOk) return true;
|
||
|
||
// 回退:搜索拿到可用歌曲 ID 再尝试下载
|
||
const searchHit = await searchSongOnTuneHub(song.name, song.artist, source);
|
||
if (searchHit?.id) {
|
||
const fallbackCandidates = buildDownloadCandidates(source, searchHit.id, searchHit.url, searchHit.types);
|
||
const fallbackOk = await tryDownloadWithCandidates(song, source, baseName, fallbackCandidates, downloadHeaders);
|
||
if (fallbackOk) return true;
|
||
}
|
||
|
||
// 换源回退:kuwo -> qq
|
||
const fallbackSources = ['kuwo', 'qq'].filter(s => s !== source);
|
||
for (const fallbackSource of fallbackSources) {
|
||
console.log(`[Sync] Trying fallback source ${fallbackSource} for ${song.name}`);
|
||
const fallbackHit = await searchSongOnTuneHub(song.name, song.artist, fallbackSource);
|
||
if (!fallbackHit?.id) {
|
||
console.warn(`[Sync] No search hit from ${fallbackSource} for ${song.name}`);
|
||
continue;
|
||
}
|
||
|
||
const fallbackHeaders = buildDownloadHeaders(fallbackSource);
|
||
const fallbackCandidates = buildDownloadCandidates(
|
||
fallbackSource,
|
||
fallbackHit.id,
|
||
fallbackHit.url,
|
||
fallbackHit.types
|
||
);
|
||
|
||
const fallbackOk = await tryDownloadWithCandidates(
|
||
song,
|
||
fallbackSource,
|
||
baseName,
|
||
fallbackCandidates,
|
||
fallbackHeaders
|
||
);
|
||
if (fallbackOk) return true;
|
||
}
|
||
|
||
return false; // Failed
|
||
}
|
||
|
||
async function searchSongInNavidrome(song) {
|
||
try {
|
||
const rawName = (song?.name || '').trim();
|
||
const rawArtist = (song?.artist || '').trim();
|
||
const rawAlbum = (song?.album || '').trim();
|
||
const idToken = song?.id ? `netease_${song.id}` : '';
|
||
|
||
const normalize = (str) => (str || '').toString().trim().toLowerCase();
|
||
const safeName = sanitizeFilename(rawName);
|
||
const safeArtist = sanitizeFilename(rawArtist);
|
||
|
||
const queries = [];
|
||
if (rawName && rawArtist) queries.push(`${rawName} ${rawArtist}`);
|
||
if (rawName) queries.push(rawName);
|
||
if (rawArtist && rawAlbum) queries.push(`${rawArtist} ${rawAlbum}`);
|
||
if (safeName) {
|
||
const fallbackName = safeArtist ? `${safeArtist} - ${safeName}` : safeName;
|
||
queries.push(idToken ? `${fallbackName} [${idToken}]` : fallbackName);
|
||
}
|
||
|
||
const seen = new Set();
|
||
const uniqueQueries = queries.filter(q => {
|
||
const key = normalize(q);
|
||
if (!key || seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
|
||
const MAX_ATTEMPTS = 2;
|
||
|
||
for (const query of uniqueQueries) {
|
||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||
const result = await callSubsonicAPI('search3', { query });
|
||
if (result.searchResult3?.song) {
|
||
const songs = Array.isArray(result.searchResult3.song)
|
||
? result.searchResult3.song
|
||
: [result.searchResult3.song];
|
||
|
||
if (idToken) {
|
||
const byPath = songs.find(s => typeof s.path === 'string' && s.path.includes(idToken));
|
||
if (byPath) return byPath.id;
|
||
}
|
||
|
||
const byTitleArtist = songs.find(s =>
|
||
normalize(s.title) === normalize(rawName) &&
|
||
normalize(s.artist) === normalize(rawArtist)
|
||
);
|
||
if (byTitleArtist) return byTitleArtist.id;
|
||
|
||
if (songs.length === 1) {
|
||
const only = songs[0];
|
||
if (normalize(only.title) === normalize(rawName)) {
|
||
return only.id;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (attempt < MAX_ATTEMPTS) {
|
||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
console.error('Search song error:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function createNavidromePlaylist(name, songIds = []) {
|
||
try {
|
||
const result = await callSubsonicAPI('createPlaylist', {
|
||
name: name,
|
||
songId: songIds
|
||
});
|
||
return result.playlist?.id;
|
||
} catch (error) {
|
||
console.error('Create playlist error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function updateNavidromePlaylist(playlistId, songIdsToAdd) {
|
||
try {
|
||
console.log(`[Sync] Updating Navidrome playlist ${playlistId} with ${songIdsToAdd.length} songs`);
|
||
await callSubsonicAPI('updatePlaylist', {
|
||
playlistId: playlistId,
|
||
songIdToAdd: songIdsToAdd
|
||
});
|
||
} catch (error) {
|
||
console.error('Update playlist error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function getNavidromePlaylistSongIds(playlistId) {
|
||
try {
|
||
const result = await callSubsonicAPI('getPlaylist', { id: playlistId });
|
||
const entries = result.playlist?.entry || result.playlist?.song || [];
|
||
const list = Array.isArray(entries) ? entries : [entries];
|
||
return list.map(e => e?.id).filter(Boolean);
|
||
} catch (error) {
|
||
console.error('Get playlist error:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function findNavidromePlaylistByName(name) {
|
||
try {
|
||
const result = await callSubsonicAPI('getPlaylists');
|
||
const playlists = result.playlists?.playlist || [];
|
||
// Subsonic returns single object if only one result, array otherwise. Normalize it.
|
||
const list = Array.isArray(playlists) ? playlists : [playlists];
|
||
|
||
const found = list.find(p => p.name === name);
|
||
return found ? found.id : null;
|
||
} catch (error) {
|
||
console.error('Find playlist error:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function syncPlaylist(playlist, cachedInfo = null) {
|
||
const playlistId = playlist.id;
|
||
syncStatus[playlistId] = { status: 'syncing', progress: 0, message: '开始同步...' };
|
||
|
||
try {
|
||
console.log(`[Sync] Syncing playlist: ${playlist.name} (${playlist.neteaseId})`);
|
||
|
||
let playlistInfo = cachedInfo;
|
||
if (!playlistInfo) {
|
||
playlistInfo = await getPlaylistInfo(playlist.neteaseId);
|
||
}
|
||
|
||
// Update playlist metadata if available
|
||
if (playlistInfo.name && playlistInfo.name !== 'Unknown Playlist') {
|
||
playlist.name = playlistInfo.name;
|
||
}
|
||
if (playlistInfo.cover) {
|
||
playlist.cover = playlistInfo.cover;
|
||
}
|
||
if (playlistInfo.description) {
|
||
playlist.description = playlistInfo.description;
|
||
}
|
||
|
||
if (!playlistInfo || !playlistInfo.tracks) {
|
||
throw new Error('Failed to get playlist tracks');
|
||
}
|
||
|
||
const tracks = playlistInfo.tracks;
|
||
const totalTracks = tracks.length;
|
||
let syncedCount = 0;
|
||
let failedCount = 0;
|
||
|
||
syncStatus[playlistId] = { status: 'syncing', progress: 10, message: `获取到 ${totalTracks} 首歌曲` };
|
||
|
||
const songs = tracks.map(track => ({
|
||
id: track.id,
|
||
name: track.name,
|
||
artist: track.ar?.[0]?.name || track.artist || 'Unknown',
|
||
album: track.al?.name || track.album || 'Unknown',
|
||
platform: 'netease',
|
||
source: 'netease',
|
||
url: track.url || '',
|
||
types: track.types || []
|
||
}));
|
||
|
||
// --- Integrated Download Logic ---
|
||
let processedCount = 0;
|
||
const total = songs.length;
|
||
|
||
// Process songs sequentially to avoid overwhelming the server/API
|
||
// Or with limited concurrency (e.g., 3)
|
||
const BATCH_SIZE = 3;
|
||
for (let i = 0; i < total; i += BATCH_SIZE) {
|
||
const batch = songs.slice(i, i + BATCH_SIZE);
|
||
await Promise.all(batch.map(async (song) => {
|
||
try {
|
||
await processSong(song);
|
||
} catch (e) {
|
||
console.error(`[Sync] Failed to process song ${song.name}:`, e);
|
||
}
|
||
}));
|
||
|
||
processedCount += batch.length;
|
||
const progress = 10 + Math.floor((processedCount / total) * 60); // 10% -> 70%
|
||
syncStatus[playlistId] = {
|
||
status: 'syncing',
|
||
progress: progress,
|
||
message: `下载/检查中 ${processedCount}/${total}`
|
||
};
|
||
}
|
||
|
||
// Trigger Scan after downloads
|
||
syncStatus[playlistId] = { status: 'syncing', progress: 75, message: '触发 Navidrome 扫描...' };
|
||
await triggerNavidromeScan();
|
||
|
||
syncStatus[playlistId] = { status: 'syncing', progress: 80, message: '匹配歌曲到 Navidrome...' };
|
||
|
||
const newSongIds = [];
|
||
const allNavidromeIds = [];
|
||
|
||
for (let i = 0; i < songs.length; i++) {
|
||
const song = songs[i];
|
||
const neteaseSongId = song.id;
|
||
|
||
let navidromeSongId = null;
|
||
|
||
if (playlist.songMapping && playlist.songMapping[neteaseSongId]) {
|
||
navidromeSongId = playlist.songMapping[neteaseSongId];
|
||
} else {
|
||
navidromeSongId = await searchSongInNavidrome(song);
|
||
|
||
if (navidromeSongId) {
|
||
newSongIds.push(navidromeSongId);
|
||
|
||
if (!playlist.songMapping) {
|
||
playlist.songMapping = {};
|
||
}
|
||
playlist.songMapping[neteaseSongId] = navidromeSongId;
|
||
}
|
||
}
|
||
|
||
if (navidromeSongId) {
|
||
allNavidromeIds.push(navidromeSongId);
|
||
syncedCount++;
|
||
} else {
|
||
failedCount++;
|
||
}
|
||
|
||
const progress = 80 + Math.floor((i + 1) / songs.length * 15); // 80% -> 95%
|
||
syncStatus[playlistId] = {
|
||
status: 'syncing',
|
||
progress: progress,
|
||
message: `已匹配 ${syncedCount}/${totalTracks} 首歌曲`
|
||
};
|
||
}
|
||
|
||
syncStatus[playlistId] = { status: 'syncing', progress: 95, message: '更新 Navidrome 歌单...' };
|
||
|
||
// 1. Try to link existing Navidrome playlist by name if ID is missing
|
||
if (!playlist.navidromePlaylistId) {
|
||
console.log(`[Sync] searching for existing Navidrome playlist with name: ${playlist.name}`);
|
||
const existingId = await findNavidromePlaylistByName(playlist.name);
|
||
if (existingId) {
|
||
console.log(`[Sync] Found existing Navidrome playlist: ${existingId}`);
|
||
playlist.navidromePlaylistId = existingId;
|
||
}
|
||
}
|
||
|
||
const uniqueAllNavidromeIds = Array.from(new Set(allNavidromeIds));
|
||
|
||
console.log(`[Sync] Matched ${uniqueAllNavidromeIds.length} songs (New: ${newSongIds.length}). PlaylistID: ${playlist.navidromePlaylistId}`);
|
||
|
||
if (playlist.navidromePlaylistId) {
|
||
// Playlist exists (either linked just now or before)
|
||
// Always check existing playlist content and补齐缺失歌曲
|
||
let missingIds = [];
|
||
if (uniqueAllNavidromeIds.length > 0) {
|
||
const existingIds = await getNavidromePlaylistSongIds(playlist.navidromePlaylistId);
|
||
if (Array.isArray(existingIds)) {
|
||
const existingSet = new Set(existingIds.map(id => id?.toString()));
|
||
missingIds = uniqueAllNavidromeIds.filter(id => !existingSet.has(id?.toString()));
|
||
console.log(`[Sync] Existing playlist has ${existingIds.length} songs, missing ${missingIds.length}`);
|
||
} else {
|
||
// Fallback: if无法读取,至少追加本次新匹配到的
|
||
missingIds = newSongIds;
|
||
}
|
||
}
|
||
|
||
if (missingIds.length > 0) {
|
||
console.log(`[Sync] Adding ${missingIds.length} missing songs to existing playlist ${playlist.navidromePlaylistId}`);
|
||
await updateNavidromePlaylist(playlist.navidromePlaylistId, missingIds);
|
||
} else {
|
||
console.log(`[Sync] No missing songs to add to playlist ${playlist.navidromePlaylistId}`);
|
||
}
|
||
} else {
|
||
// Playlist does NOT exist
|
||
// Create it with ALL matched songs (if any)
|
||
if (uniqueAllNavidromeIds.length > 0) {
|
||
console.log(`[Sync] Creating new playlist '${playlist.name}' with ${uniqueAllNavidromeIds.length} songs`);
|
||
const navidromePlaylistId = await createNavidromePlaylist(playlist.name, uniqueAllNavidromeIds);
|
||
playlist.navidromePlaylistId = navidromePlaylistId;
|
||
} else {
|
||
// Try creating empty playlist? Subsonic API usually requires songId.
|
||
// We will try with empty array, but it might fail or be rejected.
|
||
// Some servers support creating empty playlist.
|
||
try {
|
||
console.log(`[Sync] No songs matched, but attempting to create empty playlist '${playlist.name}'`);
|
||
const navidromePlaylistId = await createNavidromePlaylist(playlist.name, []);
|
||
if (navidromePlaylistId) {
|
||
playlist.navidromePlaylistId = navidromePlaylistId;
|
||
console.log(`[Sync] Created empty playlist: ${navidromePlaylistId}`);
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[Sync] Failed to create empty playlist (server might require at least one song): ${e.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
playlist.lastSyncTime = new Date().toISOString();
|
||
playlist.syncStatus = 'success';
|
||
|
||
const playlistIndex = playlists.playlists.findIndex(p => p.id === playlistId);
|
||
if (playlistIndex !== -1) {
|
||
playlists.playlists[playlistIndex] = playlist;
|
||
}
|
||
savePlaylists();
|
||
|
||
syncStatus[playlistId] = {
|
||
status: 'success',
|
||
progress: 100,
|
||
message: `同步完成: ${syncedCount} 首成功, ${failedCount} 首失败`
|
||
};
|
||
|
||
// Force save updated metadata
|
||
const finalPlaylistIndex = playlists.playlists.findIndex(p => p.id === playlistId);
|
||
if (finalPlaylistIndex !== -1) {
|
||
playlists.playlists[finalPlaylistIndex] = playlist;
|
||
}
|
||
savePlaylists();
|
||
|
||
console.log(`[Sync] Playlist sync completed: ${playlist.name}`);
|
||
|
||
} catch (error) {
|
||
console.error(`[Sync] Playlist sync failed: ${playlist.name}`, error);
|
||
|
||
playlist.syncStatus = 'failed';
|
||
playlist.lastSyncTime = new Date().toISOString();
|
||
|
||
const playlistIndex = playlists.playlists.findIndex(p => p.id === playlistId);
|
||
if (playlistIndex !== -1) {
|
||
playlists.playlists[playlistIndex] = playlist;
|
||
}
|
||
savePlaylists();
|
||
|
||
syncStatus[playlistId] = {
|
||
status: 'failed',
|
||
progress: 0,
|
||
message: `同步失败: ${error.message}`
|
||
};
|
||
}
|
||
}
|
||
|
||
app.get('/api/playlists', (req, res) => {
|
||
const playlistsWithStatus = playlists.playlists.map(p => {
|
||
const currentSyncStatus = syncStatus[p.id];
|
||
return {
|
||
...p,
|
||
status: currentSyncStatus?.status || p.syncStatus || 'idle',
|
||
syncProgress: currentSyncStatus?.progress || 0,
|
||
syncMessage: currentSyncStatus?.message || '',
|
||
songs: Object.keys(p.songMapping || {})
|
||
};
|
||
});
|
||
res.json(playlistsWithStatus);
|
||
});
|
||
|
||
app.post('/api/playlists', async (req, res) => {
|
||
const { url } = req.body;
|
||
|
||
if (!url) {
|
||
return res.status(400).json({ error: 'URL is required' });
|
||
}
|
||
|
||
const neteaseId = extractPlaylistId(url);
|
||
|
||
if (!neteaseId) {
|
||
return res.status(400).json({ error: 'Invalid playlist ID or URL' });
|
||
}
|
||
|
||
const existingPlaylist = playlists.playlists.find(p => p.neteaseId === neteaseId);
|
||
if (existingPlaylist) {
|
||
return res.status(400).json({ error: 'Playlist already exists' });
|
||
}
|
||
|
||
try {
|
||
const playlistInfo = await getPlaylistInfo(neteaseId);
|
||
|
||
const newPlaylist = {
|
||
id: `netease_${neteaseId}`,
|
||
neteaseId: neteaseId,
|
||
name: playlistInfo.name || 'Unknown Playlist',
|
||
cover: playlistInfo.cover || '',
|
||
description: playlistInfo.description || '',
|
||
navidromePlaylistId: null,
|
||
lastSyncTime: null,
|
||
syncStatus: 'idle',
|
||
songMapping: {}
|
||
};
|
||
|
||
playlists.playlists.push(newPlaylist);
|
||
savePlaylists();
|
||
|
||
const responsePlaylist = {
|
||
...newPlaylist,
|
||
songs: [],
|
||
status: 'idle'
|
||
};
|
||
|
||
res.json(responsePlaylist);
|
||
|
||
syncPlaylist(newPlaylist, playlistInfo);
|
||
|
||
} catch (error) {
|
||
console.error('Add playlist error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/playlists/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
const index = playlists.playlists.findIndex(p => p.id === id);
|
||
if (index === -1) {
|
||
return res.status(404).json({ error: 'Playlist not found' });
|
||
}
|
||
|
||
playlists.playlists.splice(index, 1);
|
||
savePlaylists();
|
||
|
||
delete syncStatus[id];
|
||
|
||
res.json({ success: true, message: 'Playlist deleted successfully' });
|
||
});
|
||
|
||
app.post('/api/playlists/:id/sync', async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
const playlist = playlists.playlists.find(p => p.id === id);
|
||
if (!playlist) {
|
||
return res.status(404).json({ error: 'Playlist not found' });
|
||
}
|
||
|
||
playlist.syncStatus = 'syncing';
|
||
savePlaylists();
|
||
|
||
syncStatus[id] = { status: 'syncing', progress: 0, message: '开始同步...' };
|
||
|
||
res.json(playlist);
|
||
|
||
syncPlaylist(playlist);
|
||
});
|
||
|
||
app.get('/api/status/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
const playlist = playlists.playlists.find(p => p.id === id);
|
||
|
||
if (!playlist) {
|
||
return res.status(404).json({ error: 'Playlist not found' });
|
||
}
|
||
|
||
const status = syncStatus[id];
|
||
if (status) {
|
||
playlist.status = status.status;
|
||
playlist.syncProgress = status.progress;
|
||
playlist.syncMessage = status.message;
|
||
} else {
|
||
playlist.status = playlist.syncStatus || 'idle';
|
||
playlist.syncProgress = 0;
|
||
playlist.syncMessage = '';
|
||
}
|
||
|
||
playlist.songs = Object.keys(playlist.songMapping || {});
|
||
|
||
res.json(playlist);
|
||
});
|
||
|
||
cron.schedule(`*/${SYNC_INTERVAL} * * * *`, () => {
|
||
console.log('[Cron] Starting scheduled sync...');
|
||
|
||
for (const playlist of playlists.playlists) {
|
||
syncPlaylist(playlist);
|
||
}
|
||
});
|
||
|
||
app.listen(PORT, () => {
|
||
console.log(`Netease-sync server running on port ${PORT}`);
|
||
console.log(`Navidrome URL: ${NAVIDROME_URL}`);
|
||
console.log(`Sync interval: ${SYNC_INTERVAL} seconds`);
|
||
});
|