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:
@@ -5,4 +5,12 @@ services:
|
||||
build: .
|
||||
ports:
|
||||
- "7481:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
sync-service:
|
||||
build: ./sync-server
|
||||
ports:
|
||||
- "7482:3001"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
88
index.html
88
index.html
@@ -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
27
nginx.conf.example
Normal 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
9
sync-server/Dockerfile
Normal 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
85
sync-server/server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user