feat(music): add Kugou (酷狗音乐) as a music source (#69)

Adds a self-contained Kugou provider (bilibili-style: direct API calls, no
embedded API server, no new npm dependency) plus full backend + WebUI wiring.

Provider (src/music/kugou.ts): search, song-url (with device registration),
lyrics (KRC decode), song detail, playlist, album, personal FM, QR login +
cookie persistence, and quality. Request signing / crypto / KRC decoding are
ported from the MIT-licensed MakcRe/KuGouMusicApi using Node's built-in
crypto and zlib (no third-party crypto packages).

Wiring: the "kugou" platform is threaded through the provider contract, queue,
play-history, bot instance/manager dispatch (getProviderFor + the -k command
flag), index/server composition, the music/player/auth routers (unified
/search/all, /quality, the platform coercions, QR login), the cookie store,
and the WebUI (search source tab + badge, SongCard badge, brand token, and a
Kugou QR/cookie login card in Settings).

Verified live during development: search, lyrics, and album playback resolve
correctly. NOT verifiable in CI (Kugou anti-bot blocks the build host's IP):
play-URL resolution, QR login, and VIP audio — these are built faithfully to
the reference and need end-to-end testing on a non-flagged IP / a Kugou
account. See the header comment in kugou.ts.

Includes src/music/kugou.test.ts (mappers + KRC→LRC). An adversarial review
pass fixed: pagination truncating on filtered counts, an ms/seconds duration
heuristic, dfid soft-fail caching, the /v5/url random-dfid fallback, the FM
body identity, and an unguarded nickname decode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-30 18:55:41 +08:00
1 parent af326a37b7
commit a12c419dd2
18 files changed
+1109 -32

No files matched your search

+17 -5
View File
@@ -72,6 +72,11 @@
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'kugou' }"
@click="selectedSource = 'kugou'"
>酷狗</button>
<button
v-if="hasLocalSongs"
class="source-btn"
@@ -89,7 +94,7 @@
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
@@ -97,7 +102,7 @@
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
@@ -182,12 +187,12 @@ const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local';
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
function loadSource(): SearchSource {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local') return stored;
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local' || stored === 'kugou') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
@@ -232,7 +237,7 @@ watch(selectedSource, (src) => {
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
watch(selectedSource, (src) => {
if ((src === 'bilibili' || src === 'local') && activeTab.value !== 'songs') {
if ((src === 'bilibili' || src === 'local' || src === 'kugou') && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
@@ -351,6 +356,7 @@ function badgeLabel(platform: string): string {
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
if (platform === 'local') return '本地';
if (platform === 'kugou') return '酷狗';
return '网易云';
}
@@ -359,6 +365,7 @@ function badgeClass(platform: string): string {
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
if (platform === 'local') return 'badge-local';
if (platform === 'kugou') return 'badge-kugou';
return 'badge-netease';
}
@@ -645,6 +652,11 @@ onMounted(() => {
color: var(--color-primary);
}
.badge-kugou {
background: var(--brand-kugou-12);
color: var(--brand-kugou);
}
.fav-badge {
position: absolute;
top: 8px;