mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
fix: address all known follow-up issues except NetEase batch precheck
Surface failures + tighten edges:
1. Toast for /play-song & /play-playlist failures. Backend now returns
{ok, message} (localized in Chinese to match the rest of the UI).
Store stashes a notification on ok=false; new Toast.vue mounted in
App.vue shows it for 3-5s then fades. Clicking the X dismisses
immediately. Sits above the player on desktop and above the mobile
tabbar on phones.
2. QQ collected playlists pagination. fcg_get_profile_order_asset.fcg
returns max 30 per call; we now loop using has_more / short-page
detection up to a 300-playlist hard cap. Single-call users (typical)
exit the loop on the first iteration so no extra requests.
3. getPlayableSongIds chunking. 100 mids per request keeps URL well
under 8KB; chunk-level errors are isolated so a transient blip on
one chunk doesn't poison the whole batch. Returns null only when
every chunk failed (caller falls back to sequential retry).
4. SourceTabs single-source mode now renders a small subdued "网易云"
or "QQ" label instead of vanishing entirely, so the user always
knows which platform's data they're looking at.
5. Hide the "我的歌单 N" count badge when N=0 — Home and Library no
longer show "我的歌单 0" with an empty grid.
6. Auth state change invalidates fetchHomeData cache. Previously, a
user who logged out as account A and into account B within 5
minutes would see A's playlists. Now we always re-check auth at
the top of fetchHomeData and bypass the TTL cache when authStatus
has changed.
Out of scope:
- NetEase analogous batch precheck (per request).
- 60s TS3 UDP idle disconnect — that's the bundled @honeybbq/teamspeak-
client UDP layer kicking when no server packet arrives in 60s. It's
baked in (constant `v=6e4`) and not exposed as an option, and root
cause is server-side or network-layer behavior we can't reach from
here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
221079b89c
commit
59fb3ee3bd
8 files changed
+212
-43
No files matched your search
+42
-22
@@ -122,39 +122,42 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
* current user — a sequential retry loop wastes time guessing.
|
* current user — a sequential retry loop wastes time guessing.
|
||||||
*
|
*
|
||||||
* The wrapper's /getMusicPlay accepts a comma-separated songmid list
|
* The wrapper's /getMusicPlay accepts a comma-separated songmid list
|
||||||
* and resolves all of them in a single upstream call (~2-3s for 100+
|
* and resolves all of them in a single upstream call. We chunk to keep
|
||||||
* songs), so this is much cheaper than per-song probing.
|
* the URL well under typical 8KB query-string limits and to keep per-
|
||||||
|
* request latency bounded (~2-3s per 100 mids).
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* - non-null Set: authoritative result. Empty Set means all songs are
|
* - non-null Set: authoritative result. Empty Set means all songs are
|
||||||
* unplayable; non-empty means filter to those mids.
|
* unplayable; non-empty means filter to those mids.
|
||||||
* - null: the batch endpoint failed (timeout/exception). Caller
|
* - null: every chunk failed. Caller should fall back to sequential
|
||||||
* should fall back to sequential retry rather than treating as
|
* retry rather than treating as "all unplayable".
|
||||||
* "all unplayable", since we don't actually know.
|
|
||||||
*
|
|
||||||
* TODO: songIds with 1000+ entries may exceed URL length; chunk if
|
|
||||||
* we ever support that scale.
|
|
||||||
*/
|
*/
|
||||||
async getPlayableSongIds(songIds: string[]): Promise<Set<string> | null> {
|
async getPlayableSongIds(songIds: string[]): Promise<Set<string> | null> {
|
||||||
if (songIds.length === 0) return new Set();
|
if (songIds.length === 0) return new Set();
|
||||||
|
|
||||||
|
const CHUNK = 100; // ~14 chars/mid * 100 + commas ≈ 1.5KB
|
||||||
|
const playable = new Set<string>();
|
||||||
|
let allChunksFailed = true;
|
||||||
|
for (let i = 0; i < songIds.length; i += CHUNK) {
|
||||||
|
const slice = songIds.slice(i, i + CHUNK);
|
||||||
try {
|
try {
|
||||||
const res = await this.api.get("/getMusicPlay", {
|
const res = await this.api.get("/getMusicPlay", {
|
||||||
params: { songmid: songIds.join(","), quality: this.quality, ...this.cookieParams },
|
params: { songmid: slice.join(","), quality: this.quality, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
const playUrlMap: Record<string, { url?: string }> | undefined =
|
const playUrlMap: Record<string, { url?: string }> | undefined =
|
||||||
res.data?.data?.playUrl;
|
res.data?.data?.playUrl;
|
||||||
// Distinguish "endpoint returned no playUrl object at all" (treat
|
if (!playUrlMap) continue; // chunk-level failure, try next
|
||||||
// as failure → null) from "returned an empty/all-unplayable map".
|
allChunksFailed = false;
|
||||||
if (!playUrlMap) return null;
|
|
||||||
const playable = new Set<string>();
|
|
||||||
for (const [mid, info] of Object.entries(playUrlMap)) {
|
for (const [mid, info] of Object.entries(playUrlMap)) {
|
||||||
if (info?.url) playable.add(mid);
|
if (info?.url) playable.add(mid);
|
||||||
}
|
}
|
||||||
return playable;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// chunk-level failure — keep going so a transient error on one
|
||||||
|
// chunk doesn't poison the whole batch.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return allChunksFailed ? null : playable;
|
||||||
|
}
|
||||||
|
|
||||||
async getSongDetail(songId: string): Promise<Song | null> {
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
// Try /getSongInfo for full metadata, but fall through to a minimal
|
// Try /getSongInfo for full metadata, but fall through to a minimal
|
||||||
@@ -433,30 +436,47 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
|
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
|
||||||
if (!pSkeyMatch) return [];
|
if (!pSkeyMatch) return [];
|
||||||
const gtk = computeGtk(pSkeyMatch[1]);
|
const gtk = computeGtk(pSkeyMatch[1]);
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const MAX_PAGES = 10; // 300-playlist hard cap; should cover any sane user
|
||||||
|
const all: Playlist[] = [];
|
||||||
try {
|
try {
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const sin = page * PAGE_SIZE;
|
||||||
|
const ein = sin + PAGE_SIZE - 1;
|
||||||
const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", {
|
const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", {
|
||||||
params: {
|
params: {
|
||||||
ct: 20,
|
ct: 20,
|
||||||
cid: 205360956,
|
cid: 205360956,
|
||||||
userid: uin,
|
userid: uin,
|
||||||
reqtype: 3,
|
reqtype: 3,
|
||||||
sin: 0,
|
sin,
|
||||||
ein: 29,
|
ein,
|
||||||
g_tk: gtk,
|
g_tk: gtk,
|
||||||
format: "json",
|
format: "json",
|
||||||
},
|
},
|
||||||
headers: { Cookie: this.cookie },
|
headers: { Cookie: this.cookie },
|
||||||
});
|
});
|
||||||
if (res.data?.code !== 0) return [];
|
if (res.data?.code !== 0) break;
|
||||||
return (res.data?.data?.cdlist ?? []).map((p: any) => ({
|
const list: any[] = res.data?.data?.cdlist ?? [];
|
||||||
|
for (const p of list) {
|
||||||
|
all.push({
|
||||||
id: String(p.dissid ?? ""),
|
id: String(p.dissid ?? ""),
|
||||||
name: p.dissname ?? "",
|
name: p.dissname ?? "",
|
||||||
coverUrl: p.logo ?? "",
|
coverUrl: p.logo ?? "",
|
||||||
songCount: p.songnum ?? 0,
|
songCount: p.songnum ?? 0,
|
||||||
platform: "qq" as const,
|
platform: "qq",
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
// Stop when upstream signals no more pages, or when this page is
|
||||||
|
// short (also indicates end). has_more is the canonical signal.
|
||||||
|
const hasMore = res.data?.data?.has_more === 1 || res.data?.data?.has_more === true;
|
||||||
|
if (!hasMore || list.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
// Return whatever we got so far on partial failure rather than dropping
|
||||||
|
// earlier pages.
|
||||||
}
|
}
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ export function createPlayerRouter(
|
|||||||
// the sequential retry path, which still has a chance.
|
// the sequential retry path, which still has a chance.
|
||||||
}
|
}
|
||||||
if (queueable.length === 0) {
|
if (queueable.length === 0) {
|
||||||
res.json({ message: `Loaded ${totalCount} songs but none were playable (likely copyright/region restrictions).` });
|
res.json({ ok: false, message: `歌单 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,12 +300,12 @@ export function createPlayerRouter(
|
|||||||
|
|
||||||
const playing = queue.current();
|
const playing = queue.current();
|
||||||
const loadedMsg = queueable.length < totalCount
|
const loadedMsg = queueable.length < totalCount
|
||||||
? `Loaded ${queueable.length} of ${totalCount} songs (rest are copyright/region restricted)`
|
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
||||||
: `Loaded ${queueable.length} songs`;
|
: `已加载 ${queueable.length} 首`;
|
||||||
if (started && playing) {
|
if (started && playing) {
|
||||||
res.json({ message: `${loadedMsg}. Now playing: ${playing.name}` });
|
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
|
||||||
} else {
|
} else {
|
||||||
res.json({ message: `${loadedMsg}, but couldn't start playback.` });
|
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Play playlist failed");
|
logger.error({ err }, "Play playlist failed");
|
||||||
@@ -330,11 +330,11 @@ export function createPlayerRouter(
|
|||||||
bot.getPlayer().resetFailures();
|
bot.getPlayer().resetFailures();
|
||||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
res.json({ message: `Cannot play: ${song.name || song.id}` });
|
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
<Player />
|
<Player />
|
||||||
|
<Toast />
|
||||||
|
|
||||||
<!-- Mobile mini player -->
|
<!-- Mobile mini player -->
|
||||||
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
|
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
|
||||||
@@ -55,6 +56,7 @@ import { useWebSocket } from './composables/useWebSocket.js';
|
|||||||
import Navbar from './components/Navbar.vue';
|
import Navbar from './components/Navbar.vue';
|
||||||
import Player from './components/Player.vue';
|
import Player from './components/Player.vue';
|
||||||
import CoverArt from './components/CoverArt.vue';
|
import CoverArt from './components/CoverArt.vue';
|
||||||
|
import Toast from './components/Toast.vue';
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
const theme = computed(() => playerStore.theme);
|
const theme = computed(() => playerStore.theme);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="sources.length >= 2" class="source-tabs">
|
<div v-if="sources.length > 0" class="source-tabs">
|
||||||
|
<template v-if="sources.length >= 2">
|
||||||
<button
|
<button
|
||||||
v-for="src in sources"
|
v-for="src in sources"
|
||||||
:key="src"
|
:key="src"
|
||||||
@@ -10,6 +11,10 @@
|
|||||||
>
|
>
|
||||||
{{ LABELS[src] }}
|
{{ LABELS[src] }}
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="source-tab-label" :title="`数据来自${LABELS[sources[0]]}`">
|
||||||
|
{{ LABELS[sources[0]] }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,6 +68,17 @@ defineEmits<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-source mode: not interactive, but tells the user which platform
|
||||||
|
// they're looking at instead of leaving them guessing.
|
||||||
|
.source-tab-label {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.source-tabs {
|
.source-tabs {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="visible && current" class="toast" :class="`toast--${current.type}`">
|
||||||
|
<Icon :icon="current.type === 'error' ? 'mdi:alert-circle' : 'mdi:information'" class="toast-icon" />
|
||||||
|
<span class="toast-msg">{{ current.message }}</span>
|
||||||
|
<button class="toast-close" @click="visible = false" aria-label="关闭">
|
||||||
|
<Icon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onUnmounted } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const visible = ref(false);
|
||||||
|
const current = ref<{ id: number; message: string; type: 'error' | 'info' } | null>(null);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.notification?.id,
|
||||||
|
() => {
|
||||||
|
if (!store.notification) return;
|
||||||
|
current.value = store.notification;
|
||||||
|
visible.value = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
visible.value = false;
|
||||||
|
}, current.value.type === 'error' ? 5000 : 3000);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(var(--player-height) + 24px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
max-width: min(90vw, 480px);
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-dropdown);
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
bottom: calc(var(--player-height) + 60px); // sit above mobile tabbar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--error {
|
||||||
|
border-color: var(--brand-netease);
|
||||||
|
.toast-icon { color: var(--brand-netease); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--info {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
.toast-icon { color: var(--color-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-msg {
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+36
-11
@@ -63,6 +63,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
bilibiliPopular: [] as Song[],
|
bilibiliPopular: [] as Song[],
|
||||||
authStatus: { netease: false, qq: false },
|
authStatus: { netease: false, qq: false },
|
||||||
lastFetchTime: 0,
|
lastFetchTime: 0,
|
||||||
|
|
||||||
|
// Transient notification for surfacing failures (e.g., "song not playable")
|
||||||
|
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
||||||
|
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -269,9 +273,16 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
notify(message: string, type: 'error' | 'info' = 'info') {
|
||||||
|
this.notification = { id: Date.now(), message, type };
|
||||||
|
},
|
||||||
|
|
||||||
async playSong(song: Song) {
|
async playSong(song: Song) {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
|
const res = await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
|
||||||
|
if (res.data?.ok === false && res.data?.message) {
|
||||||
|
this.notify(res.data.message, 'error');
|
||||||
|
}
|
||||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
@@ -293,7 +304,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
|
|
||||||
async playPlaylist(playlistId: string, platform = 'netease') {
|
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||||
|
if (res.data?.message) {
|
||||||
|
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||||
|
}
|
||||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
@@ -360,19 +374,30 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchHomeData() {
|
async fetchHomeData() {
|
||||||
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
|
// Always check auth status first — if it changed since the cached
|
||||||
return;
|
// fetch (e.g., user logged in/out as a different account), the
|
||||||
}
|
// cached playlists belong to a different user and we MUST refetch.
|
||||||
|
|
||||||
// 1. Fetch auth status for both platforms first.
|
|
||||||
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
|
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
|
||||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||||
]);
|
]);
|
||||||
this.authStatus.netease =
|
const newAuth = {
|
||||||
neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn;
|
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
|
||||||
this.authStatus.qq =
|
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
|
||||||
qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn;
|
};
|
||||||
|
const authChanged =
|
||||||
|
newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq;
|
||||||
|
this.authStatus.netease = newAuth.netease;
|
||||||
|
this.authStatus.qq = newAuth.qq;
|
||||||
|
|
||||||
|
// Cache hit only if auth is unchanged AND within TTL.
|
||||||
|
if (
|
||||||
|
!authChanged &&
|
||||||
|
this.lastFetchTime > 0 &&
|
||||||
|
Date.now() - this.lastFetchTime < HOME_CACHE_TTL
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. NetEase data: recommend playlists work anonymously; daily/user
|
// 2. NetEase data: recommend playlists work anonymously; daily/user
|
||||||
// playlists need login but Promise.allSettled isolates failures.
|
// playlists need login but Promise.allSettled isolates failures.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
<section class="section" v-if="userAvailable.length > 0">
|
<section class="section" v-if="userAvailable.length > 0">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
我的歌单
|
我的歌单
|
||||||
<span class="section-count">{{ currentUserPlaylists.length }}</span>
|
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||||
</h2>
|
</h2>
|
||||||
<div class="playlist-grid">
|
<div class="playlist-grid">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<section class="section" v-if="userAvailable.length > 0">
|
<section class="section" v-if="userAvailable.length > 0">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
我的歌单
|
我的歌单
|
||||||
<span class="section-count">{{ currentUserPlaylists.length }}</span>
|
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||||
</h2>
|
</h2>
|
||||||
<div class="playlist-grid">
|
<div class="playlist-grid">
|
||||||
|
|||||||
Reference in new issue
Block a user