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; } 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 ${song.name} (${candidate.label})`); 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 ${song.name} (${candidate.label}):`, 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`); });