mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-06 15:02:49 +08:00
Merge pull request #175 from zzstar101/feat/artist-search
This commit is contained in:
commit
41b81a6193
17 files changed
+2260
-14
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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