feat(sync): add cloud synchronization for favorites

Add a new sync service and frontend integration to allow syncing
favorites across devices using a token.

- Configure `sync-service` in docker-compose.yml on port 7482
- Add sync token input and manual sync button to SideDrawer
- Implement auto-sync logic to persist favorites to the KV store
- Add logic to merge cloud favorites with local data on initialization
This commit is contained in:
史悦
2026-01-06 11:20:06 +08:00
parent 33e3ec714e
commit ca1026d166
5 changed files with 215 additions and 2 deletions

View File

@@ -6,3 +6,11 @@ services:
ports:
- "7481:3000"
restart: unless-stopped
sync-service:
build: ./sync-server
ports:
- "7482:3001"
volumes:
- ./data:/app/data
restart: unless-stopped

View File

@@ -139,6 +139,9 @@
const { useState, useEffect, useRef, useMemo, useCallback } = React;
const API_BASE = "https://music-dl.sayqz.com";
// Use relative path for sync service, assuming Nginx proxy is configured to forward /api/kv to the sync service
const SYNC_API_BASE = "/api";
const SOURCES = [
{ id: 'netease', name: '网易云' },
{ id: 'kuwo', name: '酷我' },
@@ -245,6 +248,34 @@
}
};
// --- Sync Service ---
const syncService = {
get: async (key, token) => {
if (!token) return null;
try {
const res = await fetch(`${SYNC_API_BASE}/kv/${key}?token=${encodeURIComponent(token)}`);
if (res.ok) {
return await res.json();
}
} catch (e) {
console.error("Sync get failed", e);
}
return null;
},
set: async (key, data, token) => {
if (!token) return;
try {
await fetch(`${SYNC_API_BASE}/kv/${key}?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} catch (e) {
console.error("Sync set failed", e);
}
}
};
// --- Components ---
const Icon = ({ name, size = "", className = "", onClick }) => (
@@ -319,7 +350,7 @@
</div>
);
const SideDrawer = ({ isOpen, onClose, view, setView, quality, setQuality, onClearCache }) => {
const SideDrawer = ({ isOpen, onClose, view, setView, quality, setQuality, onClearCache, syncToken, setSyncToken, onSyncNow }) => {
if (!isOpen) return null;
return (
@@ -366,6 +397,26 @@
</select>
</div>
<div className="px-4 py-2">
<label className="block text-sm text-gray-300 mb-2">云同步密钥</label>
<div className="flex gap-2">
<input
type="text"
value={syncToken}
onChange={(e) => setSyncToken(e.target.value)}
placeholder="输入任意密钥以同步"
className="bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-sm text-white w-full focus:outline-none focus:border-primary"
/>
<button
onClick={onSyncNow}
className="bg-primary text-black px-3 py-2 rounded-lg text-sm font-bold whitespace-nowrap"
>
同步
</button>
</div>
<p className="text-xs text-gray-500 mt-1">使用相同的密钥在多端同步收藏列表</p>
</div>
<div className="px-4 py-2 mt-2">
<button
onClick={() => {
@@ -698,6 +749,7 @@
const [volume, setVolume] = useState(1);
const [favorites, setFavorites] = useState(() => JSON.parse(localStorage.getItem('th_favorites')) || []);
const [quality, setQuality] = useState(() => localStorage.getItem('th_quality') || '320k');
const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || '');
// UI State
const [showFullPlayer, setShowFullPlayer] = useState(false);
@@ -716,8 +768,40 @@
useEffect(() => { localStorage.setItem('th_playlist', JSON.stringify(playlist)); }, [playlist]);
useEffect(() => { localStorage.setItem('th_current', JSON.stringify(currentSong)); }, [currentSong]);
useEffect(() => { localStorage.setItem('th_favorites', JSON.stringify(favorites)); }, [favorites]);
useEffect(() => {
localStorage.setItem('th_favorites', JSON.stringify(favorites));
// Auto sync to cloud if token exists
if (syncToken && favorites.length > 0) {
// Debounce could be added here, but for simplicity we just sync
syncService.set('favorites', favorites, syncToken);
}
}, [favorites, syncToken]);
useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]);
useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]);
// Initial Sync
useEffect(() => {
if (syncToken) {
handleSync();
}
}, []); // Run once on mount if token exists
const handleSync = async () => {
if (!syncToken) return;
const cloudFavorites = await syncService.get('favorites', syncToken);
if (cloudFavorites && Array.isArray(cloudFavorites)) {
// Merge strategy: Combine unique songs by ID
setFavorites(prev => {
const combined = [...prev];
cloudFavorites.forEach(cloudSong => {
if (!combined.find(s => s.id === cloudSong.id)) {
combined.push(cloudSong);
}
});
return combined;
});
}
};
useEffect(() => {
if (currentSong) {

27
nginx.conf.example Normal file
View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name your-domain.com;
# 前端静态资源 (Music Canvas)
location / {
proxy_pass http://127.0.0.1:7481;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 同步服务 API 代理
# 将 /api/kv/... 转发到 sync-service 的 7482 端口
# 注意:这里去掉了 /api 前缀,因为 sync-service 直接监听 /kv
location /api/ {
proxy_pass http://127.0.0.1:7482/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 允许跨域 (如果前端和后端不在同一个域)
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
}
}

9
sync-server/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM node:18-alpine
WORKDIR /app
COPY server.js .
EXPOSE 3001
CMD ["node", "server.js"]

85
sync-server/server.js Normal file
View File

@@ -0,0 +1,85 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const DATA_DIR = process.env.DATA_DIR || './data';
const PORT = process.env.PORT || 3001;
// Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const parsedUrl = url.parse(req.url, true);
const pathname = parsedUrl.pathname;
// Path format: /kv/:key?token=...
const match = pathname.match(/^\/kv\/([a-zA-Z0-9_-]+)$/);
if (!match) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid path' }));
return;
}
const key = match[1];
const token = parsedUrl.query.token;
if (!token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Token required' }));
return;
}
// Simple file-based storage: data/{token}_{key}.json
// Using token in filename ensures isolation between users
const safeToken = token.replace(/[^a-zA-Z0-9]/g, '');
const filePath = path.join(DATA_DIR, `${safeToken}_${key}.json`);
if (req.method === 'GET') {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(data);
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(null)); // Key not found returns null
}
} else if (req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk.toString());
req.on('end', () => {
try {
// Validate JSON
JSON.parse(body);
fs.writeFileSync(filePath, body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
} else {
res.writeHead(405);
res.end();
}
});
server.listen(PORT, () => {
console.log(`Sync Server running on port ${PORT}`);
console.log(`Data directory: ${DATA_DIR}`);
});