Files
Mymusic3/Netease-sync/server.js
史悦 89a28e1bc5 feat: 添加网易云音乐同步到Navidrome的功能
新增NetEase-sync模块,实现将网易云音乐歌单同步到Navidrome的功能
修复iOS设备自动播放问题,优化播放器体验
2026-01-12 17:59:31 +08:00

392 lines
12 KiB
JavaScript

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