点击左上角菜单,打开侧边栏。
点击“导入网易云歌单”按钮。 粘贴网易云歌单分享链接。 系统将自动提取 ID,导入歌曲,并开启每 15 分钟一次的自动同步。 若要切回私有云同步,只需清空输入框内容或重新输入您的 Server Key 即可。
This commit is contained in:
176
index.html
176
index.html
@@ -313,6 +313,19 @@
|
|||||||
console.error("Get Toplist Songs failed", e);
|
console.error("Get Toplist Songs failed", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
getPlaylist: async (id, source = 'netease') => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/?type=playlist&id=${id}&source=${source}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 200 && data.data && Array.isArray(data.data.list)) {
|
||||||
|
return data.data.list;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Get playlist failed", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -418,7 +431,7 @@
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const SideDrawer = ({ isOpen, onClose, view, setView, quality, setQuality, onClearCache, syncToken, setSyncToken, onSyncNow }) => {
|
const SideDrawer = ({ isOpen, onClose, view, setView, quality, setQuality, onClearCache, syncToken, setSyncToken, onSyncNow, onImportNetease, syncMode }) => {
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [syncMsg, setSyncMsg] = useState('');
|
const [syncMsg, setSyncMsg] = useState('');
|
||||||
|
|
||||||
@@ -437,6 +450,19 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNeteaseImportClick = () => {
|
||||||
|
const url = prompt("请输入网易云歌单分享链接\n(例如: https://music.163.com/playlist?id=...)");
|
||||||
|
if (url) {
|
||||||
|
const match = url.match(/[?&]id=(\d+)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
onImportNetease(match[1]);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
alert("无法识别歌单ID,请确保链接包含 id=数字");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -484,7 +510,9 @@
|
|||||||
</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>
|
<label className="block text-sm text-gray-300 mb-2">
|
||||||
|
{syncMode === 'netease_playlist' ? '网易云歌单ID (自动同步)' : '云同步密钥'}
|
||||||
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -503,11 +531,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</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>
|
<p className="text-xs text-gray-500">
|
||||||
|
{syncMode === 'netease_playlist'
|
||||||
|
? '当前为网易云歌单同步模式,每15分钟自动刷新'
|
||||||
|
: '使用相同的密钥在多端同步收藏列表'}
|
||||||
|
</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="px-4 py-2">
|
||||||
|
<button
|
||||||
|
onClick={handleNeteaseImportClick}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Icon name="cloud-arrow-down" />
|
||||||
|
导入网易云歌单
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 px-1">导入后将开启自动同步,每15分钟获取新歌</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-2 mt-2">
|
<div className="px-4 py-2 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -878,6 +921,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'
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const [showFullPlayer, setShowFullPlayer] = useState(false);
|
const [showFullPlayer, setShowFullPlayer] = useState(false);
|
||||||
@@ -911,24 +955,54 @@
|
|||||||
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]);
|
||||||
|
|
||||||
// Auto Sync Logic
|
// Auto Sync Logic for Private Server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only auto-sync if we have a token, it matches the last successfully synced token,
|
// Only auto-sync if we are in server mode, 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.
|
||||||
// This prevents auto-syncing current favorites to a new token while typing/switching.
|
if (syncMode === 'server' && syncToken && syncToken === lastSuccessToken && JSON.stringify(favorites) !== JSON.stringify(lastSyncedFavorites)) {
|
||||||
if (syncToken && syncToken === lastSuccessToken && JSON.stringify(favorites) !== JSON.stringify(lastSyncedFavorites)) {
|
|
||||||
// Use a small timeout to avoid rapid-fire syncs if user clicks quickly
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
autoSyncFavorites();
|
autoSyncFavorites();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [favorites, syncToken, lastSuccessToken, lastSyncedFavorites]);
|
}, [favorites, syncToken, lastSuccessToken, lastSyncedFavorites, syncMode]);
|
||||||
|
|
||||||
// 1. Auto Sync: Incremental update based on diff
|
// Auto Sync Logic for Netease Playlist (Interval based)
|
||||||
|
useEffect(() => {
|
||||||
|
if (syncMode === 'netease_playlist' && syncToken) {
|
||||||
|
const doSync = async () => {
|
||||||
|
console.log("Auto syncing netease playlist...");
|
||||||
|
const songs = await api.getPlaylist(syncToken);
|
||||||
|
if (songs && songs.length > 0) {
|
||||||
|
setFavorites(prev => {
|
||||||
|
const newFavs = [...prev];
|
||||||
|
let changed = false;
|
||||||
|
// Add new songs (reverse to keep latest at top if we prepend)
|
||||||
|
[...songs].reverse().forEach(song => {
|
||||||
|
if (!newFavs.find(s => s.id === song.id)) {
|
||||||
|
newFavs.unshift({ ...song, source: 'netease' });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? newFavs : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial sync on mount/mode change
|
||||||
|
doSync();
|
||||||
|
|
||||||
|
// Interval sync (15 minutes)
|
||||||
|
const interval = setInterval(doSync, 15 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [syncMode, syncToken]);
|
||||||
|
|
||||||
|
// 1. Auto Sync: Incremental update based on diff (Server Mode)
|
||||||
const autoSyncFavorites = async () => {
|
const autoSyncFavorites = async () => {
|
||||||
if (!syncToken || syncToken !== lastSuccessToken) return;
|
if (!syncToken || syncToken !== lastSuccessToken || syncMode !== 'server') 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) || [];
|
||||||
@@ -968,10 +1042,31 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Manual Sync
|
// 2. Manual Sync (Unified Handler)
|
||||||
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
|
||||||
|
|
||||||
// Determine Mode: Switch (Overwrite Local) or Sync (Merge)
|
// Determine Mode: Switch (Overwrite Local) or Sync (Merge)
|
||||||
const isSwitchingToken = syncToken !== lastSuccessToken;
|
const isSwitchingToken = syncToken !== lastSuccessToken;
|
||||||
|
|
||||||
@@ -1024,6 +1119,61 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importNeteasePlaylist = async (id) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const songs = await api.getPlaylist(id);
|
||||||
|
if (songs && songs.length > 0) {
|
||||||
|
setFavorites(prev => {
|
||||||
|
const newFavs = [...prev];
|
||||||
|
let addedCount = 0;
|
||||||
|
[...songs].reverse().forEach(song => {
|
||||||
|
if (!newFavs.find(s => s.id === song.id)) {
|
||||||
|
newFavs.unshift({ ...song, source: 'netease' });
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
alert(`成功导入 ${addedCount} 首新歌 (共 ${songs.length} 首)`);
|
||||||
|
return newFavs;
|
||||||
|
});
|
||||||
|
// Set sync state
|
||||||
|
setSyncToken(id);
|
||||||
|
setLastSuccessToken(id); // Treat as synced
|
||||||
|
setSyncMode('netease_playlist');
|
||||||
|
// Switch view to favorites to see result
|
||||||
|
setView('favorites');
|
||||||
|
} else {
|
||||||
|
alert("未获取到歌曲,请检查歌单隐私设置或ID是否正确");
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("导入失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Allow user to switch back to server mode manually by clearing token or just editing it
|
||||||
|
// We need to detect if user manually edits input to something that is not the playlist id
|
||||||
|
useEffect(() => {
|
||||||
|
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) {
|
||||||
api.getLrc(currentSong.id, currentSong.platform || currentSong.source).then(lrc => {
|
api.getLrc(currentSong.id, currentSong.platform || currentSong.source).then(lrc => {
|
||||||
@@ -1416,6 +1566,8 @@
|
|||||||
syncToken={syncToken}
|
syncToken={syncToken}
|
||||||
setSyncToken={setSyncToken}
|
setSyncToken={setSyncToken}
|
||||||
onSyncNow={manualSyncFavorites}
|
onSyncNow={manualSyncFavorites}
|
||||||
|
onImportNetease={importNeteasePlaylist}
|
||||||
|
syncMode={syncMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top Navigation / Search Bar */}
|
{/* Top Navigation / Search Bar */}
|
||||||
|
|||||||
Reference in New Issue
Block a user