Files
Mymusic3/Netease-sync/server.js
史悦 ad972dd2a5 - Netease-sync/server.js
- 新增 formatSongLog
      - 解析失败日志包含 id/source/apiUrl
      - 下载失败日志包含 id/source/directUrl
2026-01-13 11:19:47 +08:00

1074 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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`);
});