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

+120 -1
View File
@@ -1,13 +1,38 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider } from "../../music/provider.js";
import type { MusicProvider, Song, ArtistSongPage } from "../../music/provider.js";
import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.js";
import { requireBotAccess } from "../middleware/requirePermission.js";
import { authorize } from "../middleware/authorize.js";
import { supportsPersonalLogin } from "./personal-music.js";
/** Hard cap on how many tracks one "播放全部" request may queue — a safety net
* against a pathological catalogue (and against an upstream paging bug). */
const MAX_ARTIST_QUEUE = 500;
const ARTIST_QUEUE_PAGE = 100;
/** Walks every page of an artist's catalogue (best-first, de-duplicated). */
export async function collectArtistSongs(
fetchPage: (artistId: string, offset?: number, limit?: number) => Promise<ArtistSongPage>,
artistId: string
): Promise<Song[]> {
const songs: Song[] = [];
const seen = new Set<string>();
for (let offset = 0; offset < MAX_ARTIST_QUEUE; offset += ARTIST_QUEUE_PAGE) {
const page = await fetchPage(artistId, offset, ARTIST_QUEUE_PAGE);
for (const song of page.songs) {
if (!seen.has(song.id)) {
seen.add(song.id);
songs.push(song);
}
}
if (!page.hasMore || page.songs.length === 0) break;
}
return songs;
}
export function createPlayerRouter(
botManager: BotManager,
logger: Logger,
@@ -470,6 +495,100 @@ export function createPlayerRouter(
}
});
// Play an artist's songs. An artist page queues the singer's FULL catalogue —
// never just the hot 50 — so this pages through getArtistAllSongs when the
// source can page a catalogue, and falls back to getArtistSongs (hot songs)
// when it cannot.
router.post("/:botId/play-artist", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { artistId, platform } = req.body;
if (!artistId) {
res.status(400).json({ error: "artistId is required" });
return;
}
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
if (typeof provider.getArtistSongs !== "function") {
res.status(501).json({ error: "Not supported by this provider" });
return;
}
// Whole catalogue when the source can page it (bounded by the collector's
// safety cap); otherwise the hot songs are the best it can offer.
const fetchPage = provider.getArtistAllSongs?.bind(provider);
// Stop current playback
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = fetchPage
? await collectArtistSongs(fetchPage, artistId)
: await provider.getArtistSongs(artistId, 50);
if (songs.length === 0) {
res.json({ ok: false, message: "该歌手暂无可用歌曲" });
return;
}
// Same QQ batch-resolve optimization as play-album: drop tracks that are
// region/copyright blocked instead of burning retries on them.
let queueable: { id: string }[] = songs;
const totalCount = songs.length;
const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
if (typeof qqLike.getPlayableSongIds === "function") {
const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
if (playable !== null) {
queueable = songs.filter((s: { id: string }) => playable.has(s.id));
}
}
if (queueable.length === 0) {
res.json({ ok: false, message: `歌手 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
return;
}
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
const mode = queue.getMode();
let first;
if (mode === "random" || mode === "rloop") {
const idx = Math.floor(Math.random() * queue.size());
first = queue.playAt(idx);
} else {
first = queue.play();
}
let started = first ? await bot.resolveAndPlay(first) : false;
if (first && !started) {
started = await bot.playNext(20);
}
const playing = queue.current();
const loadedMsg = queueable.length < totalCount
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
: `已加载 ${queueable.length} 首`;
if (started && playing) {
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
} else {
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
}
} catch (err) {
logger.error({ err }, "play-artist failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Play a single song by ID — resolves URL on demand. Funnels through
// bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs
// insert-and-jump, keeping the queue) lives in one place shared with chat