现在,您可以放心地设置私有云 Token 进行全量备份,同时挂载一个网易云歌单作为数据源之一,系统会自动处理好所有的数据流转。

This commit is contained in:
史悦
2026-01-07 18:26:36 +08:00
parent b79f4a8b2c
commit 36ee441c06

View File

@@ -431,7 +431,7 @@
</div> </div>
); );
const SideDrawer = ({ isOpen, onClose, view, setView, quality, setQuality, onClearCache, syncToken, setSyncToken, onSyncNow, onImportNetease, syncMode }) => { const SideDrawer = ({ isOpen, onClose, view, setView, quality, setQuality, onClearCache, syncToken, setSyncToken, onSyncNow, onImportNetease, neteasePlaylistId, onStopNeteaseSync }) => {
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState(''); const [syncMsg, setSyncMsg] = useState('');
@@ -479,14 +479,14 @@
<div className="flex-1 py-4 overflow-y-auto"> <div className="flex-1 py-4 overflow-y-auto">
<div className="px-4 mb-2 text-xs font-bold text-gray-500 uppercase tracking-wider">菜单</div> <div className="px-4 mb-2 text-xs font-bold text-gray-500 uppercase tracking-wider">菜单</div>
<nav className="space-y-1 px-2"> <nav className="space-y-1 px-2">
<button <button
onClick={() => { setView('discover'); onClose(); }} onClick={() => { setView('discover'); onClose(); }}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${view === 'discover' ? 'bg-white/10 text-primary' : 'text-gray-300 hover:bg-white/5 hover:text-white'}`} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${view === 'discover' ? 'bg-white/10 text-primary' : 'text-gray-300 hover:bg-white/5 hover:text-white'}`}
> >
<Icon name="compass" className="w-5 text-center" /> <Icon name="compass" className="w-5 text-center" />
<span>发现音乐</span> <span>发现音乐</span>
</button> </button>
<button <button
onClick={() => { setView('favorites'); onClose(); }} onClick={() => { setView('favorites'); onClose(); }}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${view === 'favorites' ? 'bg-white/10 text-primary' : 'text-gray-300 hover:bg-white/5 hover:text-white'}`} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${view === 'favorites' ? 'bg-white/10 text-primary' : 'text-gray-300 hover:bg-white/5 hover:text-white'}`}
> >
@@ -510,9 +510,7 @@
</div> </div>
<div className="px-4 py-2"> <div className="px-4 py-2">
<label className="block text-sm text-gray-300 mb-2"> <label className="block text-sm text-gray-300 mb-2">云同步密钥</label>
{syncMode === 'netease_playlist' ? '网易云歌单ID (自动同步)' : '云同步密钥'}
</label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
@@ -532,23 +530,41 @@
</div> </div>
<div className="flex justify-between items-center mt-1"> <div className="flex justify-between items-center mt-1">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{syncMode === 'netease_playlist' 私有云多端同步收藏列表
? '当前为网易云歌单同步模式每15分钟自动刷新'
: '使用相同的密钥在多端同步收藏列表'}
</p> </p>
{syncMsg && <span className="text-xs text-primary font-bold animate-[fadeIn_0.3s]">{syncMsg}</span>} {syncMsg && <span className="text-xs text-primary font-bold animate-[fadeIn_0.3s]">{syncMsg}</span>}
</div> </div>
</div> </div>
<div className="border-t border-white/10 my-2 mx-4"></div>
<div className="px-4 py-2"> <div className="px-4 py-2">
<button <label className="block text-sm text-gray-300 mb-2">关联网易云歌单</label>
onClick={handleNeteaseImportClick} {neteasePlaylistId ? (
className="w-full py-2 px-4 rounded-lg bg-red-600/20 border border-red-500/50 text-red-500 hover:bg-red-600/30 transition-colors text-sm flex items-center justify-center gap-2" <div className="bg-white/5 rounded-lg p-3 flex flex-col gap-2 border border-white/10">
> <div className="flex justify-between items-center">
<Icon name="cloud-arrow-down" /> <div className="text-xs text-primary font-mono truncate mr-2">ID: {neteasePlaylistId}</div>
导入网易云歌单 <div className="text-[10px] text-green-400 bg-green-400/10 px-1.5 py-0.5 rounded">自动更新中</div>
</button> </div>
<p className="text-xs text-gray-500 mt-1 px-1">导入后将开启自动同步每15分钟获取新歌</p> <button
onClick={onStopNeteaseSync}
className="w-full py-1.5 rounded bg-red-500/20 text-red-500 text-xs hover:bg-red-500/30 transition-colors border border-red-500/20"
>
取消关联
</button>
</div>
) : (
<button
onClick={handleNeteaseImportClick}
className="w-full py-2 px-4 rounded-lg bg-white/5 border border-white/10 text-gray-300 hover:bg-white/10 hover:text-white transition-colors text-sm flex items-center justify-center gap-2"
>
<Icon name="cloud-arrow-down" />
导入并关联歌单
</button>
)}
<p className="text-[10px] text-gray-500 mt-2 px-1">
{neteasePlaylistId ? '每15分钟自动检测新歌并加入收藏随后触发云同步。' : '导入后将自动合并新歌到收藏列表。'}
</p>
</div> </div>
<div className="px-4 py-2 mt-2"> <div className="px-4 py-2 mt-2">
@@ -921,7 +937,7 @@
const [quality, setQuality] = useState(() => localStorage.getItem('th_quality') || '320k'); const [quality, setQuality] = useState(() => localStorage.getItem('th_quality') || '320k');
const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || ''); const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || '');
const [lastSuccessToken, setLastSuccessToken] = useState(() => localStorage.getItem('th_last_success_token') || ''); const [lastSuccessToken, setLastSuccessToken] = useState(() => localStorage.getItem('th_last_success_token') || '');
const [syncMode, setSyncMode] = useState(() => localStorage.getItem('th_sync_mode') || 'server'); // 'server' | 'netease_playlist' const [neteasePlaylistId, setNeteasePlaylistId] = useState(() => localStorage.getItem('th_netease_playlist_id') || '');
// UI State // UI State
const [showFullPlayer, setShowFullPlayer] = useState(false); const [showFullPlayer, setShowFullPlayer] = useState(false);
@@ -955,26 +971,26 @@
useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]); useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]);
useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]); useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]);
useEffect(() => { localStorage.setItem('th_last_success_token', lastSuccessToken); }, [lastSuccessToken]); useEffect(() => { localStorage.setItem('th_last_success_token', lastSuccessToken); }, [lastSuccessToken]);
useEffect(() => { localStorage.setItem('th_sync_mode', syncMode); }, [syncMode]); useEffect(() => { localStorage.setItem('th_netease_playlist_id', neteasePlaylistId); }, [neteasePlaylistId]);
// Auto Sync Logic for Private Server // Auto Sync Logic for Private Server
useEffect(() => { useEffect(() => {
// Only auto-sync if we are in server mode, have a token, it matches the last successfully synced token, // Only auto-sync if we have a token, it matches the last successfully synced token,
// and there are actual changes compared to last sync. // and there are actual changes compared to last sync.
if (syncMode === 'server' && syncToken && syncToken === lastSuccessToken && JSON.stringify(favorites) !== JSON.stringify(lastSyncedFavorites)) { if (syncToken && syncToken === lastSuccessToken && JSON.stringify(favorites) !== JSON.stringify(lastSyncedFavorites)) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
autoSyncFavorites(); autoSyncFavorites();
}, 1000); }, 1000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [favorites, syncToken, lastSuccessToken, lastSyncedFavorites, syncMode]); }, [favorites, syncToken, lastSuccessToken, lastSyncedFavorites]);
// Auto Sync Logic for Netease Playlist (Interval based) // Auto Sync Logic for Netease Playlist (Interval based)
useEffect(() => { useEffect(() => {
if (syncMode === 'netease_playlist' && syncToken) { if (neteasePlaylistId) {
const doSync = async () => { const doSync = async () => {
console.log("Auto syncing netease playlist..."); console.log("Auto syncing netease playlist...");
const songs = await api.getPlaylist(syncToken); const songs = await api.getPlaylist(neteasePlaylistId);
if (songs && songs.length > 0) { if (songs && songs.length > 0) {
setFavorites(prev => { setFavorites(prev => {
const newFavs = [...prev]; const newFavs = [...prev];
@@ -991,18 +1007,20 @@
} }
}; };
// Initial sync on mount/mode change // Initial sync on mount/mode change (if not already handled by import)
// We can skip initial immediate sync if we assume import did it, but it doesn't hurt to check.
// Actually, if we just refreshed page, we want to check.
doSync(); doSync();
// Interval sync (15 minutes) // Interval sync (15 minutes)
const interval = setInterval(doSync, 15 * 60 * 1000); const interval = setInterval(doSync, 15 * 60 * 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
} }
}, [syncMode, syncToken]); }, [neteasePlaylistId]);
// 1. Auto Sync: Incremental update based on diff (Server Mode) // 1. Auto Sync: Incremental update based on diff (Server Mode)
const autoSyncFavorites = async () => { const autoSyncFavorites = async () => {
if (!syncToken || syncToken !== lastSuccessToken || syncMode !== 'server') return; if (!syncToken || syncToken !== lastSuccessToken) return;
try { try {
// Get latest remote state // Get latest remote state
const remoteFavorites = await syncService.get('favorites', syncToken) || []; const remoteFavorites = await syncService.get('favorites', syncToken) || [];
@@ -1046,25 +1064,6 @@
const manualSyncFavorites = async () => { const manualSyncFavorites = async () => {
if (!syncToken) return; if (!syncToken) return;
// If in Netease Mode, manual sync means fetching playlist again
if (syncMode === 'netease_playlist') {
const songs = await api.getPlaylist(syncToken);
if (songs && songs.length > 0) {
setFavorites(prev => {
const newFavs = [...prev];
[...songs].reverse().forEach(song => {
if (!newFavs.find(s => s.id === song.id)) {
newFavs.unshift({ ...song, source: 'netease' });
}
});
return newFavs;
});
} else {
throw new Error("获取歌单失败");
}
return;
}
// Server Mode Sync // Server Mode Sync
// Determine Mode: Switch (Overwrite Local) or Sync (Merge) // Determine Mode: Switch (Overwrite Local) or Sync (Merge)
@@ -1136,10 +1135,9 @@
alert(`成功导入 ${addedCount} 首新歌 (共 ${songs.length} 首)`); alert(`成功导入 ${addedCount} 首新歌 (共 ${songs.length} 首)`);
return newFavs; return newFavs;
}); });
// Set sync state
setSyncToken(id); setNeteasePlaylistId(id);
setLastSuccessToken(id); // Treat as synced
setSyncMode('netease_playlist');
// Switch view to favorites to see result // Switch view to favorites to see result
setView('favorites'); setView('favorites');
} else { } else {
@@ -1153,26 +1151,11 @@
} }
}; };
// Allow user to switch back to server mode manually by clearing token or just editing it const stopNeteaseSync = () => {
// We need to detect if user manually edits input to something that is not the playlist id if(confirm("确定要停止自动同步网易云歌单吗?已导入的歌曲不会被删除。")) {
useEffect(() => { setNeteasePlaylistId('');
if (syncMode === 'netease_playlist' && syncToken !== lastSuccessToken) {
// If user changes token, revert to server mode implicitly?
// Or wait for them to click sync?
// For now, let's keep it simple. If they click "Import Netease", it forces Netease mode.
// If they type a new token and click Sync, it will try to fetch playlist if mode is netease,
// likely fail, then user might be confused.
// Better: If user types in input, we assume they might want to switch mode if it fails?
// Let's rely on the import button to set the mode.
// If user wants to go back to server mode, they can clear token or we add a toggle?
// The input label changes, so user knows.
} }
};
// If user clears token, reset mode to server (default)
if (!syncToken) {
setSyncMode('server');
}
}, [syncToken]);
useEffect(() => { useEffect(() => {
if (currentSong) { if (currentSong) {
@@ -1567,7 +1550,8 @@
setSyncToken={setSyncToken} setSyncToken={setSyncToken}
onSyncNow={manualSyncFavorites} onSyncNow={manualSyncFavorites}
onImportNetease={importNeteasePlaylist} onImportNetease={importNeteasePlaylist}
syncMode={syncMode} neteasePlaylistId={neteasePlaylistId}
onStopNeteaseSync={stopNeteaseSync}
/> />
{/* Top Navigation / Search Bar */} {/* Top Navigation / Search Bar */}