feat: 实现网易云音乐同步服务核心功能与UI改进

添加完整的网易云音乐同步到Navidrome的功能实现,包括:
1. 新增Docker支持与相关配置文件
2. 实现歌单同步逻辑与Navidrome API集成
3. 改进前端UI界面与交互体验
4. 添加状态监控与错误处理机制
5. 实现定时同步功能与进度显示
This commit is contained in:
史悦
2026-01-12 20:03:30 +08:00
parent 89a28e1bc5
commit 50f7869a05
11 changed files with 2331 additions and 386 deletions

View File

@@ -1,13 +1,56 @@
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';
@@ -24,6 +67,14 @@ 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 = {};
@@ -50,10 +101,19 @@ function savePlaylists() {
}
function extractPlaylistId(input) {
const urlMatch = input.match(/playlist[/?]id=(\d+)/);
if (urlMatch) {
return urlMatch[1];
// 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;
@@ -81,7 +141,7 @@ async function getSubsonicUrl(endpoint, params = {}) {
async function callSubsonicAPI(endpoint, params = {}) {
try {
const url = await getSubsonicUrl(endpoint, params);
const response = await axios.get(url);
const response = await apiClient.get(url, { retry: 3, retryDelay: 1000 });
if (response.data['subsonic-response'].status === 'ok') {
return response.data['subsonic-response'];
} else {
@@ -96,10 +156,40 @@ async function callSubsonicAPI(endpoint, params = {}) {
async function getPlaylistInfo(neteaseId) {
try {
const url = `${TUNEHUB_API_URL}/api/?source=netease&id=${neteaseId}&type=playlist`;
const response = await axios.get(url);
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) {
return response.data.data;
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) {
@@ -108,17 +198,251 @@ async function getPlaylistInfo(neteaseId) {
}
}
async function sendToSyncServer(songs) {
async function triggerNavidromeScan() {
try {
const url = `${SYNC_SERVER_URL}/kv/netease_sync_songs?token=${SYNC_SERVER_TOKEN}`;
await axios.post(url, songs);
console.log(`Sent ${songs.length} songs to sync-server`);
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.error('Sync-server error:', error.message);
throw 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) {
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);
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);
});
});
}
function processSong(song) {
return new Promise((resolve) => {
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(true); // Exists
return;
}
} catch (e) {
console.error('Error reading music dir:', e);
}
// Get URL (prefer FLAC)
const apiUrl = `${TUNEHUB_API_URL}/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 = `${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);
} 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);
resolve(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);
}
resolve(true); // Downloaded (fallback)
}
})
.catch(err => {
console.error(`[Sync] Download failed for ${song.name}:`, err.message);
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
resolve(false); // Failed
});
};
// 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();
resolve(false);
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(false);
return;
}
handleDownload(url);
} catch (e) {
console.error('[Sync] API Parse Error:', e);
resolve(false);
}
});
}).on('error', (e) => {
console.error(`[Sync] API Request Error: ${e.message}`);
resolve(false);
});
});
}
async function searchSongInNavidrome(filename) {
try {
const result = await callSubsonicAPI('search3', { query: filename });
@@ -165,14 +489,43 @@ async function updateNavidromePlaylist(playlistId, songIdsToAdd) {
}
}
async function syncPlaylist(playlist) {
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})`);
const playlistInfo = await getPlaylistInfo(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');
@@ -194,59 +547,125 @@ async function syncPlaylist(playlist) {
source: 'netease'
}));
await sendToSyncServer(songs);
// --- Integrated Download Logic ---
let processedCount = 0;
const total = songs.length;
syncStatus[playlistId] = { status: 'syncing', progress: 30, message: '歌曲下载中...' };
// 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();
await new Promise(resolve => setTimeout(resolve, 5000));
syncStatus[playlistId] = { status: 'syncing', progress: 50, message: '匹配歌曲到 Navidrome...' };
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]) {
syncedCount++;
continue;
}
const safeName = song.name.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, '_');
const safeArtist = song.artist.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, '_');
const filename = `${safeArtist} - ${safeName} [netease_${neteaseSongId}]`;
const navidromeSongId = await searchSongInNavidrome(filename);
if (navidromeSongId) {
newSongIds.push(navidromeSongId);
navidromeSongId = playlist.songMapping[neteaseSongId];
} else {
const safeName = sanitizeFilename(song.name);
const safeArtist = sanitizeFilename(song.artist);
const filename = `${safeArtist} - ${safeName} [netease_${neteaseSongId}]`;
if (!playlist.songMapping) {
playlist.songMapping = {};
navidromeSongId = await searchSongInNavidrome(filename);
if (navidromeSongId) {
newSongIds.push(navidromeSongId);
if (!playlist.songMapping) {
playlist.songMapping = {};
}
playlist.songMapping[neteaseSongId] = navidromeSongId;
}
playlist.songMapping[neteaseSongId] = navidromeSongId;
}
if (navidromeSongId) {
allNavidromeIds.push(navidromeSongId);
syncedCount++;
} else {
failedCount++;
}
const progress = 50 + Math.floor((i + 1) / songs.length * 40);
syncStatus[playlistId] = {
status: 'syncing',
progress: progress,
message: `已匹配 ${syncedCount}/${totalTracks} 首歌曲`
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: 90, message: '更新 Navidrome 歌单...' };
syncStatus[playlistId] = { status: 'syncing', progress: 95, message: '更新 Navidrome 歌单...' };
if (newSongIds.length > 0) {
if (playlist.navidromePlaylistId) {
// 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;
}
}
console.log(`[Sync] Matched ${allNavidromeIds.length} songs (New: ${newSongIds.length}). PlaylistID: ${playlist.navidromePlaylistId}`);
if (playlist.navidromePlaylistId) {
// Playlist exists (either linked just now or before)
// Just append new songs if any
if (newSongIds.length > 0) {
console.log(`[Sync] Adding ${newSongIds.length} new songs to existing playlist ${playlist.navidromePlaylistId}`);
await updateNavidromePlaylist(playlist.navidromePlaylistId, newSongIds);
} else {
const navidromePlaylistId = await createNavidromePlaylist(playlist.name, newSongIds);
console.log(`[Sync] No new songs to add to playlist ${playlist.navidromePlaylistId}`);
}
} else {
// Playlist does NOT exist
// Create it with ALL matched songs (if any)
if (allNavidromeIds.length > 0) {
console.log(`[Sync] Creating new playlist '${playlist.name}' with ${allNavidromeIds.length} songs`);
const navidromePlaylistId = await createNavidromePlaylist(playlist.name, allNavidromeIds);
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}`);
}
}
}
@@ -259,12 +678,19 @@ async function syncPlaylist(playlist) {
}
savePlaylists();
syncStatus[playlistId] = {
status: 'success',
progress: 100,
message: `同步完成: ${syncedCount} 首成功, ${failedCount} 首失败`
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) {
@@ -288,21 +714,27 @@ async function syncPlaylist(playlist) {
}
app.get('/api/playlists', (req, res) => {
const playlistsWithStatus = playlists.playlists.map(p => ({
...p,
currentStatus: syncStatus[p.id] || { status: p.syncStatus || 'idle', message: '' }
}));
res.json({ playlists: playlistsWithStatus });
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 { input } = req.body;
const { url } = req.body;
if (!input) {
return res.status(400).json({ error: 'Input is required' });
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
const neteaseId = extractPlaylistId(input);
const neteaseId = extractPlaylistId(url);
if (!neteaseId) {
return res.status(400).json({ error: 'Invalid playlist ID or URL' });
@@ -331,9 +763,15 @@ app.post('/api/playlists', async (req, res) => {
playlists.playlists.push(newPlaylist);
savePlaylists();
res.json({ success: true, playlist: newPlaylist });
const responsePlaylist = {
...newPlaylist,
songs: [],
status: 'idle'
};
syncPlaylist(newPlaylist);
res.json(responsePlaylist);
syncPlaylist(newPlaylist, playlistInfo);
} catch (error) {
console.error('Add playlist error:', error);
@@ -354,7 +792,7 @@ app.delete('/api/playlists/:id', (req, res) => {
delete syncStatus[id];
res.json({ success: true });
res.json({ success: true, message: 'Playlist deleted successfully' });
});
app.post('/api/playlists/:id/sync', async (req, res) => {
@@ -365,15 +803,38 @@ app.post('/api/playlists/:id/sync', async (req, res) => {
return res.status(404).json({ error: 'Playlist not found' });
}
res.json({ success: true });
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 status = syncStatus[id] || { status: 'idle', message: '' };
res.json(status);
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} * * * *`, () => {