diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..fb5cbfe
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,131 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## 项目概述
+
+没事Music 是一个基于 Web 的音乐播放器应用,采用单文件 React 架构,支持多平台音乐搜索、播放和同步功能。
+
+## 架构设计
+
+### 前端架构 (index.html)
+
+**单文件应用模式**:
+- 所有前端代码集中在 `index.html` (~89KB)
+- 使用 Babel Standalone 在浏览器端编译 JSX
+- React 18 + Tailwind CSS + Font Awesome (均通过 CDN 引入)
+
+**核心组件结构**:
+```
+App (主容器)
+├── SideDrawer (侧边栏设置)
+├── MiniPlayer (迷你播放器)
+├── FullPlayer (全屏播放器)
+├── PlaylistDrawer (播放列表)
+└── TopListPage (排行榜页面)
+```
+
+**状态管理**:
+- 使用 React Hooks (useState, useEffect, useRef, useMemo, useCallback)
+- 数据持久化: localStorage (前缀 `th_`)
+- 关键状态: playlist, favorites, currentSong, syncToken, syncMode
+
+**数据规范化**:
+- `normalizeSongId()`: 全局强制歌曲 ID 为字符串类型
+- `normalizeSongList()`: 批量规范化歌曲列表
+- `getSongDurationSeconds()`: 统一处理不同来源的时长字段 (duration/dt/time/interval/length/playTime)
+
+### 后端架构 (sync-server/server.js)
+
+**KV 存储服务**:
+- 基于 Node.js 原生 HTTP 服务器
+- 文件系统存储: `data/{token}_{key}.json`
+- API 端点: `GET/POST /kv/:key?token=...`
+- Token 用于用户隔离
+
+**后台下载管理器**:
+- 自动检测歌曲列表数据并触发下载
+- 文件命名: `{歌手} - {歌名} [{平台}_{ID}]`
+- 文件名清理: Unicode 规范化 (NFC) + 非法字符替换
+- 存储路径: `MUSIC_DIR` (默认 `./music`)
+
+### 同步策略
+
+**两种同步模式**:
+1. **私有云同步** (`syncMode: 'server'`):
+ - 基于 Token 的 KV 存储
+ - 增量同步: 计算本地与上次同步的差异 (added/removed)
+ - 自动同步: 每 15 分钟执行一次
+
+2. **网易云歌单同步** (`syncMode: 'netease_playlist'`):
+ - 通过 TuneHub API 获取歌单
+ - 导入即替换逻辑
+ - 自动同步: 每 15 分钟执行一次
+
+**同步流程**:
+```
+拉取远程 → 计算差异 → 合并本地(非覆盖) → 推送至私有云
+```
+
+### 外部 API 集成
+
+**TuneHub API** (https://music-dl.sayqz.com):
+- 搜索: `/api/?type=search&keyword=...&source=...`
+- 歌词: `/api/?source=...&id=...&type=lrc`
+- 排行榜列表: `/api/?source=...&type=toplists`
+- 排行榜歌曲: `/api/?source=...&id=...&type=toplist`
+- 歌单详情: `/api/?type=playlist&id=...&source=...`
+
+**支持平台**: netease, kuwo, qq, kugou
+
+## 部署架构
+
+### Docker Compose
+
+```yaml
+services:
+ music-canvas: # 前端静态网站
+ ports: 7481:3000
+
+ sync-service: # 同步服务
+ ports: 7482:3001
+ volumes:
+ - ./data:/app/data # KV 数据存储
+ - ./music:/app/music # 音乐文件存储
+```
+
+### Nginx 配置
+
+- 前端: `/` → `http://127.0.0.1:7481`
+- 同步 API: `/api/` → `http://127.0.0.1:7482/` (去掉 `/api` 前缀)
+
+## 开发注意事项
+
+### 代码修改原则
+
+1. **ID 类型一致性**: 所有歌曲 ID 必须保持字符串类型,使用 `normalizeSongId()` 处理
+2. **文件名清理**: 使用 Unicode NFC 规范化 + 非法字符替换,避免跨平台问题
+3. **状态同步**: 修改 favorites/playlist 后,确保同步逻辑正确触发
+4. **API 调用**: 所有外部 API 调用通过 TuneHub API,不要直接调用各平台 API
+
+### 关键常量
+
+```javascript
+API_BASE = "https://music-dl.sayqz.com"
+SYNC_API_BASE = "/api" // 相对路径,依赖 Nginx 代理
+SOURCES = ['netease', 'kuwo', 'qq', 'kugou']
+```
+
+### 本地开发
+
+前端无需构建,直接用浏览器打开 `index.html` 即可。如需测试同步功能:
+```bash
+cd sync-server
+node server.js # 默认端口 3001
+```
+
+### 环境变量 (sync-server)
+
+- `DATA_DIR`: KV 数据存储目录 (默认 `./data`)
+- `MUSIC_DIR`: 音乐文件存储目录 (默认 `./music`)
+- `PORT`: 服务端口 (默认 `3001`)
\ No newline at end of file
diff --git a/Netease-sync/package.json b/Netease-sync/package.json
new file mode 100644
index 0000000..ba5ee9b
--- /dev/null
+++ b/Netease-sync/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "netease-sync",
+ "version": "1.0.0",
+ "description": "Sync Netease playlists to Navidrome",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "express": "^4.18.2",
+ "axios": "^1.6.0",
+ "node-cron": "^3.0.3"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+}
diff --git a/Netease-sync/public/app.js b/Netease-sync/public/app.js
new file mode 100644
index 0000000..39e7314
--- /dev/null
+++ b/Netease-sync/public/app.js
@@ -0,0 +1,219 @@
+const API_BASE = '/api';
+
+let playlists = [];
+
+function showError(message) {
+ const errorDiv = document.getElementById('errorMessage');
+ errorDiv.textContent = message;
+ errorDiv.style.display = 'block';
+ setTimeout(() => {
+ errorDiv.style.display = 'none';
+ }, 5000);
+}
+
+function showSuccess(message) {
+ const successDiv = document.getElementById('successMessage');
+ successDiv.textContent = message;
+ successDiv.style.display = 'block';
+ setTimeout(() => {
+ successDiv.style.display = 'none';
+ }, 3000);
+}
+
+function formatTimestamp(timestamp) {
+ if (!timestamp) return '从未同步';
+ const date = new Date(timestamp);
+ return date.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+}
+
+function getStatusClass(status) {
+ switch (status) {
+ case 'syncing':
+ return 'status-syncing';
+ case 'success':
+ return 'status-success';
+ case 'error':
+ return 'status-error';
+ default:
+ return 'status-idle';
+ }
+}
+
+function getStatusText(status) {
+ switch (status) {
+ case 'syncing':
+ return '同步中';
+ case 'success':
+ return '同步成功';
+ case 'error':
+ return '同步失败';
+ default:
+ return '待同步';
+ }
+}
+
+function renderPlaylists() {
+ const container = document.getElementById('playlistList');
+
+ if (playlists.length === 0) {
+ container.innerHTML = `
+
+
+
还没有添加任何歌单
+
在上方输入网易云音乐歌单ID或分享链接开始同步
+
+ `;
+ return;
+ }
+
+ container.innerHTML = playlists.map(playlist => `
+
+
+
${playlist.name}
+
+ 🆔 ${playlist.neteaseId}
+ 🎵 ${playlist.songs.length} 首歌曲
+ ⏰ ${formatTimestamp(playlist.lastSyncTime)}
+ ${getStatusText(playlist.status)}
+
+
+
+
+
+
+
+ `).join('');
+}
+
+async function loadPlaylists() {
+ try {
+ const response = await fetch(`${API_BASE}/playlists`);
+ if (!response.ok) throw new Error('加载歌单失败');
+ playlists = await response.json();
+ renderPlaylists();
+ } catch (error) {
+ console.error('加载歌单失败:', error);
+ showError('加载歌单失败: ' + error.message);
+ }
+}
+
+async function addPlaylist() {
+ const input = document.getElementById('playlistInput');
+ const value = input.value.trim();
+
+ if (!value) {
+ showError('请输入歌单ID或分享链接');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${API_BASE}/playlists`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ url: value })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || '添加歌单失败');
+ }
+
+ const playlist = await response.json();
+ playlists.push(playlist);
+ renderPlaylists();
+ input.value = '';
+ showSuccess('歌单添加成功');
+ } catch (error) {
+ console.error('添加歌单失败:', error);
+ showError('添加歌单失败: ' + error.message);
+ }
+}
+
+async function deletePlaylist(id) {
+ if (!confirm('确定要删除这个歌单吗?')) return;
+
+ try {
+ const response = await fetch(`${API_BASE}/playlists/${id}`, {
+ method: 'DELETE'
+ });
+
+ if (!response.ok) throw new Error('删除歌单失败');
+
+ playlists = playlists.filter(p => p.id !== id);
+ renderPlaylists();
+ showSuccess('歌单删除成功');
+ } catch (error) {
+ console.error('删除歌单失败:', error);
+ showError('删除歌单失败: ' + error.message);
+ }
+}
+
+async function syncPlaylist(id) {
+ try {
+ const response = await fetch(`${API_BASE}/playlists/${id}/sync`, {
+ method: 'POST'
+ });
+
+ if (!response.ok) throw new Error('启动同步失败');
+
+ const playlist = await response.json();
+ const index = playlists.findIndex(p => p.id === id);
+ if (index !== -1) {
+ playlists[index] = playlist;
+ }
+ renderPlaylists();
+ showSuccess('同步已启动');
+ } catch (error) {
+ console.error('启动同步失败:', error);
+ showError('启动同步失败: ' + error.message);
+ }
+}
+
+async function updatePlaylistStatus(id) {
+ try {
+ const response = await fetch(`${API_BASE}/status/${id}`);
+ if (!response.ok) return;
+
+ const playlist = await response.json();
+ const index = playlists.findIndex(p => p.id === id);
+ if (index !== -1) {
+ playlists[index] = playlist;
+ renderPlaylists();
+ }
+ } catch (error) {
+ console.error('更新状态失败:', error);
+ }
+}
+
+function startStatusPolling() {
+ setInterval(() => {
+ playlists.forEach(playlist => {
+ if (playlist.status === 'syncing') {
+ updatePlaylistStatus(playlist.id);
+ }
+ });
+ }, 3000);
+}
+
+document.getElementById('playlistInput').addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ addPlaylist();
+ }
+});
+
+loadPlaylists();
+startStatusPolling();
\ No newline at end of file
diff --git a/Netease-sync/public/index.html b/Netease-sync/public/index.html
new file mode 100644
index 0000000..85e40ae
--- /dev/null
+++ b/Netease-sync/public/index.html
@@ -0,0 +1,230 @@
+
+
+
+
+
+ 网易云音乐同步到 Navidrome
+
+
+
+
+
🎵 网易云音乐同步到 Navidrome
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Netease-sync/server.js b/Netease-sync/server.js
new file mode 100644
index 0000000..89d2e0b
--- /dev/null
+++ b/Netease-sync/server.js
@@ -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`);
+});
diff --git a/Netease-sync/需求.md b/Netease-sync/需求.md
new file mode 100644
index 0000000..233bf92
--- /dev/null
+++ b/Netease-sync/需求.md
@@ -0,0 +1,141 @@
+# Netease-sync 需求文档
+
+## 项目概述
+在当前目录下新建一个简单的 Node.js 项目,实现和 Navidrome 的联动,将网易云音乐歌单同步到 Navidrome。
+
+## 核心功能
+
+### 1. 歌单输入
+- 前台页面支持输入网易云音乐的歌单 ID 或分享链接
+- 支持从歌单 URL 中提取 ID(如 `https://music.163.com/#/playlist?id=123456` 提取 `123456`)
+- 只支持网易云音乐平台
+
+### 2. 歌单读取
+- 调用 TuneHub API 获取歌单详情
+- API: `GET /api/?source=netease&id={playlistId}&type=playlist`
+- 获取歌单名称、封面、描述、歌曲列表等元数据
+
+### 3. 歌曲下载
+- 调用现有的 sync-server 下载逻辑
+- 包括元数据写入(title, artist, album, cover)
+- **不创建歌单缓存 JSON 文件**,避免和主 app 混淆
+- 下载到 sync-server 的 music 目录(Navidrome 也读取此目录)
+- 如果下载失败,尝试换源(根据 api.md 支持的平台:netease, kuwo, qq)
+- **不重试**,失败则跳过
+
+### 4. Navidrome 歌单创建
+- 使用 Navidrome 的 Subsonic API
+- 在 Navidrome 中创建同名歌单
+- 将下载的歌曲加入歌单
+- 同步歌单的所有元数据(封面、描述等)
+
+### 5. 同步策略
+- **增量同步**:只新增歌曲,不删除已有歌曲
+- 通过网易云音乐歌曲 ID 判断,在本地维护一个映射表(netease_song_id -> navidrome_song_id)
+- **定时同步**:每 300 秒自动同步一次(可配置)
+- 支持手动立即同步
+
+### 6. 前端界面
+- 显示已添加的歌单列表
+- 显示每个歌单的同步状态(同步中/成功/失败)
+- 显示上次同步时间
+- 提供立即同步按钮
+- 支持添加/删除歌单
+
+## 配置项
+
+### Docker 环境变量
+- `NAVIDROME_URL`: Navidrome 服务器地址(如 `http://navidrome:4533`)
+- `NAVIDROME_USERNAME`: Navidrome 用户名
+- `NAVIDROME_PASSWORD`: Navidrome 密码
+- `SYNC_INTERVAL`: 同步间隔(秒),默认 300
+- `SYNC_SERVER_URL`: sync-server 地址(如 `http://sync-service:3001`)
+- `SYNC_SERVER_TOKEN`: sync-server 的 token(用于 KV API)
+- `TUNEHUB_API_URL`: TuneHub API 地址(如 `https://music-dl.sayqz.com`)
+
+## 技术架构
+
+### 后端
+- Node.js + Express
+- 调用 sync-server 的 KV API 传递歌曲列表
+- 调用 Navidrome Subsonic API 创建歌单
+- 定时任务:每 N 秒检查并同步歌单
+- 数据持久化:JSON 文件存储歌单列表和映射表
+
+### 前端
+- 简单的 HTML + JavaScript 页面
+- 实时显示同步状态(WebSocket 或轮询)
+- 添加/删除歌单功能
+
+## Subsonic API 集成
+
+### 认证
+所有 API 请求需要包含以下参数:
+- `u`: 用户名
+- `p`: 密码(明文或 hex 格式)
+- `v`: API 版本(如 `1.16.0`)
+- `c`: 客户端名称(如 `netease-sync`)
+
+### 主要端点
+1. **创建歌单**: `createPlaylist`
+ - 参数:`name`(歌单名称)、`songId`(歌曲 ID 列表,逗号分隔)
+ - 返回:创建的歌单 ID
+
+2. **更新歌单**: `updatePlaylist`
+ - 参数:`playlistId`(歌单 ID)、`songIdToAdd`(要添加的歌曲 ID)
+
+3. **获取歌单列表**: `getPlaylists`
+
+4. **搜索歌曲**: `search3`
+ - 参数:`query`(搜索关键词)
+ - 用于根据文件名查找 Navidrome 中的歌曲 ID
+
+## 歌曲匹配策略
+
+参考 sync-server 的去重逻辑,文件名格式为:`Artist - Name [source_id].ext`
+
+匹配步骤:
+1. 下载完成后,Navidrome 会自动扫描新歌曲
+2. 通过 `search3` 搜索文件名(如 `Artist - Name [netease_id]`)来获取 Navidrome 的歌曲 ID
+3. 将网易云音乐歌曲 ID 映射到 Navidrome 歌曲 ID
+
+## 数据结构
+
+### 歌单列表 (playlists.json)
+```json
+{
+ "playlists": [
+ {
+ "id": "netease_123456",
+ "neteaseId": "123456",
+ "name": "歌单名称",
+ "cover": "封面URL",
+ "description": "歌单描述",
+ "navidromePlaylistId": "navidrome_playlist_id",
+ "lastSyncTime": "2025-01-12T10:00:00Z",
+ "syncStatus": "success",
+ "songMapping": {
+ "netease_song_id_1": "navidrome_song_id_1",
+ "netease_song_id_2": "navidrome_song_id_2"
+ }
+ }
+ ]
+}
+```
+
+## 换源逻辑
+
+如果网易云音乐下载失败,尝试换源:
+- 优先级:kuwo -> qq
+- 每个平台只尝试一次,不重试
+
+## 错误处理
+
+- 某首歌下载失败,跳过继续下载其他歌曲
+- 不重试
+- 错误日志输出到 Docker 控制台,不存储
+
+## Navidrome 扫描延迟
+
+- Navidrome 扫描新歌曲很快,不处理延迟
+- 在添加到歌单时直接搜索歌曲 ID
diff --git a/index.html b/index.html
index f097d77..135649c 100644
--- a/index.html
+++ b/index.html
@@ -148,6 +148,14 @@
{ id: 'qq', name: 'QQ音乐' },
{ id: 'kugou', name: '酷狗' }
];
+ const IS_IOS = (() => {
+ if (typeof navigator === 'undefined') return false;
+ const ua = navigator.userAgent || '';
+ const platform = navigator.platform || '';
+ const iOSUA = /iPad|iPhone|iPod/.test(ua);
+ const iPadOS = platform === 'MacIntel' && navigator.maxTouchPoints > 1;
+ return iOSUA || iPadOS;
+ })();
// --- Utility Functions ---
const formatTime = (seconds) => {
@@ -945,6 +953,7 @@
const audioRef = useRef(null);
const autoAdvanceLockRef = useRef(false);
+ const autoNextPendingRef = useRef(false);
const currentSongRef = useRef(null);
useEffect(() => {
@@ -955,6 +964,33 @@
autoAdvanceLockRef.current = false;
}, [currentSong]);
+ const playAudioWithFallback = (audio, options = {}) => {
+ if (!audio) return;
+ const { deferOnIOS = false } = options;
+ const doPlay = () => {
+ const playPromise = audio.play();
+ if (playPromise && typeof playPromise.catch === 'function') {
+ playPromise.catch(e => {
+ console.warn("Auto-play prevented:", e);
+ if (e && e.name === 'AbortError') return;
+ setIsPlaying(false);
+ });
+ }
+ };
+
+ if (deferOnIOS && IS_IOS) {
+ const onCanPlay = () => {
+ audio.removeEventListener('canplay', onCanPlay);
+ doPlay();
+ };
+ audio.addEventListener('canplay', onCanPlay);
+ try { audio.load(); } catch (e) {}
+ return;
+ }
+
+ doPlay();
+ };
+
// Media Session Refs
const playNextRef = useRef(null);
const playPrevRef = useRef(null);
@@ -1185,30 +1221,39 @@
const triggerAutoNext = () => {
if (autoAdvanceLockRef.current) return;
autoAdvanceLockRef.current = true;
- playNext(true, { immediate: true });
+ playNext(true, { immediate: !IS_IOS, deferOnIOS: IS_IOS });
+ };
+
+ const isNearEnd = () => {
+ const durationSeconds = resolveDurationSeconds(audio, currentSongRef.current);
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
+ const threshold = IS_IOS ? 0.15 : 0.35;
+ return audio.currentTime >= durationSeconds - threshold;
};
const updateTime = () => {
setCurrentTime(audio.currentTime);
if (autoAdvanceLockRef.current) return;
- const durationSeconds = resolveDurationSeconds(audio, currentSongRef.current);
- if (Number.isFinite(durationSeconds) && durationSeconds > 0) {
- if (audio.currentTime >= durationSeconds - 0.35) {
- triggerAutoNext();
- }
- }
+ if (!IS_IOS && isNearEnd()) triggerAutoNext();
};
const updateDuration = () => setDuration(resolveDurationSeconds(audio, currentSongRef.current));
const onEnded = () => triggerAutoNext();
+ const onPause = () => {
+ if (!IS_IOS) return;
+ if (autoAdvanceLockRef.current) return;
+ if (isNearEnd()) triggerAutoNext();
+ };
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('ended', onEnded);
+ audio.addEventListener('pause', onPause);
return () => {
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('loadedmetadata', updateDuration);
audio.removeEventListener('ended', onEnded);
+ audio.removeEventListener('pause', onPause);
};
}, [playlist, currentSong, mode, volume, quality]);
@@ -1222,20 +1267,21 @@
// Only update src if it's different to avoid reloading same song on re-render (unless quality changed)
// Note: audioRef.current.src returns full absolute URL
const currentSrc = audio.src;
+ const wasPlaying = isPlaying;
+ const deferOnIOS = autoNextPendingRef.current;
+ autoNextPendingRef.current = false;
// Simple check if src changed significantly (avoiding minor encoding diffs if possible, but exact match is safer)
if (currentSrc !== url) {
- const wasPlaying = isPlaying;
audio.src = url;
if (wasPlaying) {
- audio.play()
- .then(() => setIsPlaying(true))
- .catch(e => {
- console.warn("Auto-play prevented:", e);
- setIsPlaying(false);
- });
+ playAudioWithFallback(audio, { deferOnIOS });
}
+ } else if (deferOnIOS && wasPlaying) {
+ playAudioWithFallback(audio, { deferOnIOS: true });
}
+ } else {
+ autoNextPendingRef.current = false;
}
}, [currentSong, quality]); // Re-run when quality changes
@@ -1243,7 +1289,7 @@
if (currentSong) {
const audio = audioRef.current;
if (!audio) return;
- if (isPlaying) audio.play().catch(() => setIsPlaying(false));
+ if (isPlaying) playAudioWithFallback(audio);
else audio.pause();
}
}, [isPlaying]);
@@ -1325,11 +1371,15 @@
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
- audio.play().catch(() => setIsPlaying(false));
+ playAudioWithFallback(audio);
autoAdvanceLockRef.current = false;
return;
}
+ if (auto) {
+ autoNextPendingRef.current = true;
+ }
+
let nextIdx;
const currIdx = playlist.findIndex(s => s.id === currentSong?.id);
@@ -1350,12 +1400,7 @@
if (audio.src !== url) {
audio.src = url;
}
- audio.play()
- .then(() => setIsPlaying(true))
- .catch(e => {
- console.warn("Auto-play prevented:", e);
- setIsPlaying(false);
- });
+ playAudioWithFallback(audio, { deferOnIOS: options.deferOnIOS });
}
};