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:
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) {
|
||||
|
||||
Reference in New Issue
Block a user