Files
Mymusic3/Netease-sync/server.js
史悦 b5b093e64b feat(同步): 改进网易云音乐到Navidrome的播放列表同步逻辑
添加获取Navidrome播放列表歌曲ID的功能
确保同步时检查并补全缺失歌曲,避免重复添加
使用Set去重处理歌曲ID列表
2026-01-13 09:33:44 +08:00

936 lines
34 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) {
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(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'
}));
// --- 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`);
});