Merge pull request #175 from zzstar101/feat/artist-search

This commit is contained in:
TIANYAO ZHANG committed 2026-10-03 16:50:09 +08:00
commit 41b81a6193
17 files changed
+2260 -14

No files matched your search

+114
View File
@@ -503,3 +503,117 @@ describe("music router GET /bilibili/parts", () => {
expect(res.status).toBe(404);
});
});
describe("music router GET /artist/:id", () => {
function artistProvider(overrides: Record<string, unknown> = {}): MusicProvider {
return {
platform: "netease",
search: vi.fn().mockResolvedValue(empty),
getArtistDetail: vi.fn().mockResolvedValue({
id: "6452",
name: "Adele",
avatarUrl: "http://p/1.jpg",
platform: "netease",
description: "English singer",
}),
getArtistSongs: vi.fn().mockResolvedValue([
{ id: "1", name: "Hello", artist: "Adele", album: "25", duration: 295, coverUrl: "c", platform: "netease" },
]),
getArtistAlbums: vi.fn().mockResolvedValue([
{ id: "a1", name: "25", artist: "Adele", coverUrl: "c", songCount: 11, platform: "netease" },
]),
...overrides,
} as unknown as MusicProvider;
}
function mount(netease: MusicProvider, qq: MusicProvider = fakeProvider("qq")) {
const app = express();
app.use("/api/music", createMusicRouter(netease, qq, fakeProvider("bilibili"), pino({ level: "silent" })));
return app;
}
it("returns artist detail, hot songs and albums for the requested platform", async () => {
const netease = artistProvider();
const res = await request(mount(netease)).get("/api/music/artist/6452?platform=netease");
expect(res.status).toBe(200);
expect(res.body.artist).toMatchObject({ id: "6452", name: "Adele", description: "English singer" });
expect(res.body.songs).toHaveLength(1);
expect(res.body.albums).toHaveLength(1);
expect(netease.getArtistDetail).toHaveBeenCalledWith("6452");
expect(netease.getArtistSongs).toHaveBeenCalledWith("6452");
expect(netease.getArtistAlbums).toHaveBeenCalledWith("6452");
});
it("routes to the QQ provider when platform=qq", async () => {
const qq = artistProvider({ platform: "qq" });
const res = await request(mount(fakeProvider("netease"), qq)).get("/api/music/artist/abc?platform=qq");
expect(res.status).toBe(200);
expect(qq.getArtistDetail).toHaveBeenCalledWith("abc");
});
it("404s when the provider has no such artist", async () => {
const netease = artistProvider({ getArtistDetail: vi.fn().mockResolvedValue(null) });
const res = await request(mount(netease)).get("/api/music/artist/999");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Artist not found");
});
it("501s when the provider does not support artists at all", async () => {
const res = await request(mount(fakeProvider("netease"))).get("/api/music/artist/1");
expect(res.status).toBe(501);
expect(res.body.error).toBe("Not supported by this provider");
});
it("degrades each leg independently — a failing songs/albums call still returns the hero", async () => {
const netease = artistProvider({
getArtistSongs: vi.fn().mockRejectedValue(new Error("boom")),
getArtistAlbums: vi.fn().mockRejectedValue(new Error("boom")),
});
const res = await request(mount(netease)).get("/api/music/artist/6452");
expect(res.status).toBe(200);
expect(res.body.artist.name).toBe("Adele");
expect(res.body.songs).toEqual([]);
expect(res.body.albums).toEqual([]);
});
it("tolerates a provider that only implements getArtistDetail", async () => {
const netease = artistProvider({ getArtistSongs: undefined, getArtistAlbums: undefined });
const res = await request(mount(netease)).get("/api/music/artist/6452");
expect(res.status).toBe(200);
expect(res.body.songs).toEqual([]);
expect(res.body.albums).toEqual([]);
});
});
describe("music router GET /search/all artist aggregation", () => {
function searchProvider(platform: MusicProvider["platform"], artists: unknown[]): MusicProvider {
return {
platform,
search: vi.fn().mockResolvedValue({ ...empty, artists }),
} as unknown as MusicProvider;
}
it("merges artists from netease and qq and ignores sources without artists", async () => {
const app = express();
app.use(
"/api/music",
createMusicRouter(
searchProvider("netease", [{ id: "1", name: "N", avatarUrl: "", platform: "netease" }]),
searchProvider("qq", [{ id: "2", name: "Q", avatarUrl: "", platform: "qq" }]),
fakeProvider("bilibili"),
pino({ level: "silent" })
)
);
const res = await request(app).get("/api/music/search/all?q=adele");
expect(res.status).toBe(200);
expect(res.body.artists).toEqual([
{ id: "1", name: "N", avatarUrl: "", platform: "netease" },
{ id: "2", name: "Q", avatarUrl: "", platform: "qq" },
]);
});
});
+39 -3
View File
@@ -1,5 +1,5 @@
import express, { Router, type Response } from "express";
import type { MusicProvider, Song, Album } from "../../music/provider.js";
import type { MusicProvider, Song, Album, SearchResult } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
@@ -198,7 +198,7 @@ export function createMusicRouter(
// searched. Jellyfin (an opt-in source) leads the merged results when
// enabled — a self-hosted library match is almost always the wanted one.
const enabled = (p: string) => !config || isProviderEnabled(config, p);
const none = { songs: [], albums: [], playlists: [] };
const none: SearchResult = { songs: [], albums: [], playlists: [] };
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
enabled("netease") ? neteaseProvider.search(q as string, parsedLimit) : Promise.resolve(none),
@@ -226,8 +226,14 @@ export function createMusicRouter(
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
// Artists come only from the sources that model them (netease/qq); other
// providers simply contribute nothing.
const artists = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.artists ?? [] : []),
...(qqResult.status === "fulfilled" ? qqResult.value.artists ?? [] : []),
];
res.json({ songs, albums, playlists });
res.json({ songs, albums, playlists, artists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
@@ -297,6 +303,36 @@ export function createMusicRouter(
}
});
router.get("/artist/:id", async (req, res) => {
try {
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getArtistDetail) {
res.status(501).json({ error: "Not supported by this provider" });
return;
}
// Each piece degrades independently: a source that cannot list albums (or
// a transient upstream failure) must not take the hero or the songs down
// with it, so every call falls back to an empty value.
const [artist, songs, albums] = await Promise.all([
provider.getArtistDetail(req.params.id).catch(() => null),
provider.getArtistSongs
? provider.getArtistSongs(req.params.id).catch(() => [] as Song[])
: Promise.resolve([] as Song[]),
provider.getArtistAlbums
? provider.getArtistAlbums(req.params.id).catch(() => [] as Album[])
: Promise.resolve([] as Album[]),
]);
if (!artist) {
res.status(404).json({ error: "Artist not found" });
return;
}
res.json({ artist, songs, albums });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
try {
const provider = resolveProvider(req.query.platform, res);
+3 -1
View File
@@ -374,13 +374,15 @@ describe("guest enforcement on player routes", () => {
expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403);
});
it("playCollection flag gates /play-playlist, /play-album (issue #103)", async () => {
it("playCollection flag gates /play-playlist, /play-album, /play-artist (issue #103)", async () => {
const allow = mountGuest({ playCollection: true });
const deny = mountGuest({ playCollection: false });
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-artist`).send({ artistId: "1" })).status).not.toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-artist`).send({ artistId: "1" })).status).toBe(403);
// playCollection does NOT leak into the destructive single-song / queue ops.
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { collectArtistSongs } from "./player.js";
import type { ArtistSongPage, Song } from "../../music/provider.js";
function song(id: string): Song {
return {
id,
name: `song-${id}`,
artist: "Adele",
album: "25",
duration: 200,
coverUrl: "c",
platform: "netease",
};
}
function page(ids: string[], total: number, hasMore: boolean): ArtistSongPage {
return { songs: ids.map(song), total, hasMore };
}
describe("collectArtistSongs (play-artist all:true)", () => {
it("walks every page until hasMore is false and de-duplicates ids", async () => {
const pages: Record<number, ArtistSongPage> = {
0: page(["1", "2"], 4, true),
100: page(["2", "3"], 4, true),
200: page(["4"], 4, false),
};
const fetchPage = vi.fn(async (_id: string, offset = 0, _limit = 100) => pages[offset] ?? page([], 4, false));
const songs = await collectArtistSongs(fetchPage as any, "artist-1");
expect(songs.map((s) => s.id)).toEqual(["1", "2", "3", "4"]);
expect(fetchPage.mock.calls.map((c) => c[1])).toEqual([0, 100, 200]);
expect(fetchPage.mock.calls[0][2]).toBe(100);
});
it("stops at the 500-track safety cap", async () => {
let n = 0;
const fetchPage = vi.fn(async () => ({
songs: Array.from({ length: 100 }, () => song(String(n++))),
total: 100000,
hasMore: true,
}));
const songs = await collectArtistSongs(fetchPage as any, "a");
expect(songs).toHaveLength(500);
expect(fetchPage).toHaveBeenCalledTimes(5);
});
it("stops on an empty page even when hasMore claims otherwise", async () => {
const fetchPage = vi.fn(async () => page([], 9, true));
expect(await collectArtistSongs(fetchPage as any, "a")).toEqual([]);
expect(fetchPage).toHaveBeenCalledTimes(1);
});
it("returns the first page unchanged when it is already complete", async () => {
const fetchPage = vi.fn(async () => page(["1"], 1, false));
const songs = await collectArtistSongs(fetchPage as any, "a");
expect(songs.map((s) => s.id)).toEqual(["1"]);
expect(fetchPage).toHaveBeenCalledTimes(1);
});
});
+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