feat: 添加网易云音乐同步到Navidrome的功能
新增NetEase-sync模块,实现将网易云音乐歌单同步到Navidrome的功能 修复iOS设备自动播放问题,优化播放器体验
This commit is contained in:
391
Netease-sync/server.js
Normal file
391
Netease-sync/server.js
Normal file
@@ -0,0 +1,391 @@
|
||||
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`);
|
||||
});
|
||||
Reference in New Issue
Block a user