mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-06 15:02:49 +08:00
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:
1 parent
87fca6d8b7
commit
b6ad536bb7
17 files changed
+2260
-14
No files matched your search
+120
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user