feat(web): artist search, artist pages, and full-catalogue playback

Adds artist support for the NetEase and QQ providers plus the matching UI.

Backend:
- SearchResult gains `artists`; new optional MusicProvider methods
  getArtistDetail / getArtistSongs / getArtistAlbums / getArtistAllSongs.
- NetEase: /cloudsearch type=100 for artist search, /artists, /artist/songs,
  /artist/album and /artist/desc for the artist page.
- QQ: singer search rides along in the existing musicu.fcg batch
  (search_type=1); singer detail via music.web_singer_info_svr. QQ exposes no
  working singer-song paging endpoint, so the full catalogue is built from the
  hot 50 plus every album of the singer (album search filtered by singerMID,
  songs fetched per album, de-duplicated, cached for 10 minutes). A failed
  album sweep is never cached and degrades to the hot list.
- API: GET /api/music/artist/:id and POST /api/player/:botId/play-artist
  (player.control capability, guest flag playCollection); /search/all now
  aggregates artists too.

Frontend:
- Search history in localStorage (max 10, per platform, never shared between
  users), shown as a dropdown under the search box and as 最近搜索 chips.
- Artist row in the search results; new /artist/:id page (portrait, aliases,
  stats, description, top songs, album shelf) with 播放 / 随机播放, which queue
  the singer's whole catalogue.
- playArtist store action.

Tests cover the provider mappers, the new routes, the play-artist collector
(paging, de-duplication, 500-track cap), permission gating and the new views.
This commit is contained in:
zzstar101 committed 2026-10-02 01:36:03 +08:00
1 parent 87fca6d8b7
commit b6ad536bb7
17 files changed
+2260 -14

No files matched your search

+255 -2
View File
@@ -10,6 +10,9 @@ import type {
QrCodeResult,
AuthStatus,
Album,
Artist,
ArtistDetail,
ArtistSongPage,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
@@ -40,6 +43,28 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
/** True when a search_type=2 album search entry really belongs to this singer.
* QQ fills singerMID for most albums; older entries only carry singer_list. */
function isArtistAlbum(a: any, artistId: string): boolean {
const mid = a?.singerMID ?? a?.singer_mid;
if (mid) return String(mid) === artistId;
const singers = a?.singer_list ?? a?.singer ?? [];
return (
Array.isArray(singers) &&
singers.some((s: any) => String(s?.mid ?? s?.singerMID ?? "") === artistId)
);
}
/** Assembling a QQ singer's full catalogue costs one album-song request per
* album, so the merged list is memoised per singer for a while. */
const ARTIST_CATALOG_TTL_MS = 10 * 60 * 1000;
const ARTIST_CATALOG_MAX_ENTRIES = 20;
/** Album search pages to walk (50 albums each) and the hard cap on albums we
* are willing to fetch songs for. */
const ARTIST_ALBUM_MAX_PAGES = 3;
const ARTIST_ALBUM_MAX = 50;
const ARTIST_ALBUM_CONCURRENCY = 5;
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s) => {
@@ -91,6 +116,33 @@ export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
});
}
/** QQ hands out http:// image URLs; the WebUI is often served over https. */
function httpsImage(url: unknown): string {
return typeof url === "string" ? url.replace(/^http:\/\//i, "https://") : "";
}
export function mapQqArtists(raw: any[] | null | undefined): Artist[] {
if (!Array.isArray(raw)) return [];
return raw
.map((a) => {
const id = String(a.singerMID ?? a.singer_mid ?? a.mid ?? a.singerID ?? a.singerId ?? "");
const mid = a.singerMID ?? a.singer_mid ?? a.mid;
return {
id,
name: a.singerName ?? a.name ?? "",
// Search returns a 150px portrait; the MID builds the 500px one the
// artist page wants, so prefer it and only fall back to the given URL.
avatarUrl: mid
? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg`
: httpsImage(a.singerPic ?? a.pic),
songCount: a.songNum ?? undefined,
albumCount: a.albumNum ?? undefined,
platform: "qq" as const,
};
})
.filter((a) => a.id && a.name);
}
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -195,6 +247,13 @@ export class QQMusicProvider implements MusicProvider {
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
},
// search_type 1 = singers. Folded into the same batch so artist search
// costs no extra round-trip.
req_artist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 1 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
@@ -202,7 +261,11 @@ export class QQMusicProvider implements MusicProvider {
const songList: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const artistList: any[] =
res.data?.req_artist?.data?.body?.singer?.list ?? [];
// Only fall back to the older client_search_cp path when the batch came
// back completely empty — an artist-only hit is a real result.
if (songList.length === 0 && artistList.length === 0) return null;
const songs = mapQqSongs(songList);
@@ -218,7 +281,7 @@ export class QQMusicProvider implements MusicProvider {
platform: "qq" as const,
}));
return { songs, playlists, albums };
return { songs, playlists, albums, artists: mapQqArtists(artistList) };
} catch {
return null;
}
@@ -470,6 +533,196 @@ export class QQMusicProvider implements MusicProvider {
return mapQqSongs(res.data?.response?.data?.list ?? []);
}
/** music.web_singer_info_svr / get_singer_detail_info — singer info plus up
* to `num` of their hottest songs (sort 5 = popularity). Returns null on any
* failure so callers can degrade instead of throwing. */
private async fetchSingerDetail(singerMid: string, num: number): Promise<any | null> {
try {
const reqData = JSON.stringify({
req_0: {
module: "music.web_singer_info_svr",
method: "get_singer_detail_info",
param: {
singermid: singerMid,
sort: 5,
num: Math.max(1, Math.min(num, 50)),
begin: 0,
},
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
return res.data?.req_0?.data ?? null;
} catch {
return null;
}
}
async getArtistDetail(artistId: string): Promise<ArtistDetail | null> {
const data = await this.fetchSingerDetail(artistId, 1);
if (!data) return null;
const info = data.singer_info ?? {};
const mid = String(info.mid ?? artistId);
if (!mid) return null;
return {
id: mid,
name: info.name ?? "",
avatarUrl: `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg`,
aliases: info.other_name ? [String(info.other_name)] : [],
songCount: data.total_song ?? undefined,
albumCount: data.total_album ?? undefined,
platform: "qq" as const,
description: data.singer_brief ?? "",
};
}
async getArtistSongs(artistId: string, limit = 50): Promise<Song[]> {
const data = await this.fetchSingerDetail(artistId, limit);
return mapQqSongs(data?.songlist ?? []);
}
/**
* One page of the singer's full catalogue. get_singer_detail_info ignores its
* `begin` parameter (begin=0/50/100 all return the same top 50 — verified
* 2026-10) and QQ has no working singer-song-list endpoint, so the catalogue
* is assembled from every album the singer owns: the hot 50 first (they rank
* best) followed by the album tracks, de-duplicated by songmid.
*/
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
const catalogue = await this.buildArtistCatalog(artistId);
const safeOffset = Math.max(0, Math.trunc(offset) || 0);
const safeLimit = Math.max(1, Math.min(Math.trunc(limit) || 50, 100));
const songs = catalogue.slice(safeOffset, safeOffset + safeLimit);
return {
songs,
total: catalogue.length,
hasMore: safeOffset + songs.length < catalogue.length,
};
}
/** Memoised full catalogues, keyed by singer MID (see ARTIST_CATALOG_TTL_MS). */
private artistCatalog = new Map<string, { at: number; songs: Song[] }>();
private async buildArtistCatalog(artistId: string): Promise<Song[]> {
const cached = this.artistCatalog.get(artistId);
if (cached && Date.now() - cached.at < ARTIST_CATALOG_TTL_MS) return cached.songs;
const merged: Song[] = [];
const seen = new Set<string>();
const push = (song: Song) => {
if (!song.id || seen.has(song.id)) return;
seen.add(song.id);
merged.push(song);
};
for (const song of await this.getArtistSongs(artistId, 50)) push(song);
// null = the album search could not be enumerated (transient upstream
// failure). The hot list is still returned, but such a degraded catalogue
// must NOT be cached: a 10-minute cache would silently turn "play the whole
// singer" into "play the hot 50".
const albumIds = await this.fetchArtistAlbumIds(artistId);
let albumFetchFailed = false;
for (let i = 0; albumIds && i < albumIds.length; i += ARTIST_ALBUM_CONCURRENCY) {
const batch = albumIds.slice(i, i + ARTIST_ALBUM_CONCURRENCY);
const lists = await Promise.all(
batch.map((mid) =>
this.getAlbumSongs(mid).catch(() => {
albumFetchFailed = true;
return [] as Song[];
})
)
);
for (const list of lists) {
for (const song of list) push(song);
}
}
if (albumIds !== null && !albumFetchFailed) {
if (this.artistCatalog.size >= ARTIST_CATALOG_MAX_ENTRIES) {
const oldest = this.artistCatalog.keys().next().value;
if (oldest !== undefined) this.artistCatalog.delete(oldest);
}
this.artistCatalog.set(artistId, { at: Date.now(), songs: merged });
}
return merged;
}
/** Every album MID the singer owns, via paged album search (see getArtistAlbums).
* null = the search failed, so the album list is unknown (not empty). */
private async fetchArtistAlbumIds(artistId: string): Promise<string[] | null> {
const detail = await this.fetchSingerDetail(artistId, 1);
const name = detail?.singer_info?.name;
if (!name) return [];
const ids: string[] = [];
const seen = new Set<string>();
for (let page = 1; page <= ARTIST_ALBUM_MAX_PAGES; page++) {
// One immediate retry absorbs a transient upstream hiccup, which would
// otherwise look exactly like "this singer has no albums".
const list =
(await this.searchArtistAlbums(name, page, 50)) ?? (await this.searchArtistAlbums(name, page, 50));
if (list === null) return null;
if (list.length === 0) break;
let added = 0;
for (const entry of list) {
if (!isArtistAlbum(entry, artistId)) continue;
const mid = String(entry?.albumMID ?? entry?.album_mid ?? "");
if (mid && !seen.has(mid)) {
seen.add(mid);
ids.push(mid);
added++;
}
}
if (ids.length >= ARTIST_ALBUM_MAX) break;
// A page that adds nothing means the search has drifted off this singer.
if (added === 0) break;
}
return ids.slice(0, ARTIST_ALBUM_MAX);
}
/** search_type=2 album search for a singer name — raw entries, null on failure. */
private async searchArtistAlbums(
name: string,
pageNum: number,
numPerPage: number
): Promise<any[] | null> {
try {
const reqData = JSON.stringify({
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: {
query: name,
num_per_page: Math.max(10, Math.min(numPerPage, 50)),
page_num: pageNum,
search_type: 2,
},
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
return res.data?.req_album?.data?.body?.album?.list ?? [];
} catch {
return null;
}
}
async getArtistAlbums(artistId: string, limit = 20): Promise<Album[]> {
// QQ has no working "albums for this singer MID" endpoint: the homepage tab
// API returns a null AlbumList and music.web_singer_info_svr/get_singer_album
// returns an empty list even with a logged-in cookie (verified 2026-10).
// The album shelf is therefore built from the album search for the singer's
// name, filtered down to entries whose singerMID actually matches.
const detail = await this.fetchSingerDetail(artistId, 1);
const name = detail?.singer_info?.name;
if (!name) return [];
const list = (await this.searchArtistAlbums(name, 1, limit)) ?? [];
const mine = list.filter((a: any) => isArtistAlbum(a, artistId));
return mapQqAlbums(mine).slice(0, limit);
}
async getLyrics(songId: string): Promise<LyricLine[]> {
const res = await this.api.get("/getLyric", {
params: { songmid: songId, ...this.cookieParams },