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

@@ -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) {