const express = require('express'); const axios = require('axios'); const cron = require('node-cron'); const fs = require('fs'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const DATA_DIR = process.env.DATA_DIR || './data'; 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 }); } 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) { const urlMatch = input.match(/playlist[/?]id=(\d+)/); if (urlMatch) { return urlMatch[1]; } 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 queryString = Object.entries(authParams) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); return `${NAVIDROME_URL}/rest/${endpoint}?${queryString}`; } async function callSubsonicAPI(endpoint, params = {}) { try { const url = await getSubsonicUrl(endpoint, params); const response = await axios.get(url); 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`; const response = await axios.get(url); if (response.data.code === 200) { return response.data.data; } else { throw new Error(response.data.message || 'Failed to get playlist info'); } } catch (error) { console.error('TuneHub API error:', error.message); throw error; } } async function sendToSyncServer(songs) { 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`); } catch (error) { console.error('Sync-server error:', error.message); throw error; } } async function searchSongInNavidrome(filename) { try { const result = await callSubsonicAPI('search3', { query: filename }); if (result.searchResult3?.song) { const songs = Array.isArray(result.searchResult3.song) ? result.searchResult3.song : [result.searchResult3.song]; for (const song of songs) { if (song.title && song.path) { return song.id; } } } 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.join(',') }); return result.playlist?.id; } catch (error) { console.error('Create playlist error:', error.message); throw error; } } async function updateNavidromePlaylist(playlistId, songIdsToAdd) { try { await callSubsonicAPI('updatePlaylist', { playlistId: playlistId, songIdToAdd: songIdsToAdd.join(',') }); } catch (error) { console.error('Update playlist error:', error.message); throw error; } } async function syncPlaylist(playlist) { 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); 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' })); await sendToSyncServer(songs); syncStatus[playlistId] = { status: 'syncing', progress: 30, message: '歌曲下载中...' }; await new Promise(resolve => setTimeout(resolve, 5000)); syncStatus[playlistId] = { status: 'syncing', progress: 50, message: '匹配歌曲到 Navidrome...' }; const newSongIds = []; for (let i = 0; i < songs.length; i++) { const song = songs[i]; const neteaseSongId = song.id; 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); if (!playlist.songMapping) { playlist.songMapping = {}; } playlist.songMapping[neteaseSongId] = navidromeSongId; syncedCount++; } else { failedCount++; } const progress = 50 + Math.floor((i + 1) / songs.length * 40); syncStatus[playlistId] = { status: 'syncing', progress: progress, message: `已匹配 ${syncedCount}/${totalTracks} 首歌曲` }; } syncStatus[playlistId] = { status: 'syncing', progress: 90, message: '更新 Navidrome 歌单...' }; if (newSongIds.length > 0) { if (playlist.navidromePlaylistId) { await updateNavidromePlaylist(playlist.navidromePlaylistId, newSongIds); } else { const navidromePlaylistId = await createNavidromePlaylist(playlist.name, newSongIds); playlist.navidromePlaylistId = navidromePlaylistId; } } 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} 首失败` }; 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 => ({ ...p, currentStatus: syncStatus[p.id] || { status: p.syncStatus || 'idle', message: '' } })); res.json({ playlists: playlistsWithStatus }); }); app.post('/api/playlists', async (req, res) => { const { input } = req.body; if (!input) { return res.status(400).json({ error: 'Input is required' }); } const neteaseId = extractPlaylistId(input); 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(); res.json({ success: true, playlist: newPlaylist }); syncPlaylist(newPlaylist); } 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 }); }); 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' }); } res.json({ success: true }); syncPlaylist(playlist); }); app.get('/api/status/:id', (req, res) => { const { id } = req.params; const status = syncStatus[id] || { status: 'idle', message: '' }; res.json(status); }); 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`); });