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

+131 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
import {
parseLyrics,
mapNeteaseAlbums,
mapNeteaseSongs,
mapNeteaseArtists,
parseNeteaseTrial,
NeteaseProvider,
} from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -138,6 +145,75 @@ describe("NeteaseProvider.search pagination", () => {
expect(callByType(get, 1000).offset).toBe(0);
expect(callByType(get, 10).offset).toBe(0);
});
it("requests artists (type=100) and returns them alongside songs/albums/playlists", async () => {
const p = new NeteaseProvider("http://x");
const get = vi.fn(async (_path: string, cfg: any) => ({
data:
cfg.params.type === 100
? { result: { artists: [{ id: 6452, name: "Adele", picUrl: "http://p/1.jpg", musicSize: 120 }] } }
: { result: { songs: [], playlists: [], albums: [] } },
}));
(p as any).api = { get };
const res = await p.search("adele", 20, 0);
expect(callByType(get, 100).limit).toBe(20);
expect(callByType(get, 100).offset).toBe(0);
expect(res.artists).toEqual([
{
id: "6452",
name: "Adele",
avatarUrl: "http://p/1.jpg",
aliases: [],
songCount: 120,
albumCount: undefined,
platform: "netease",
},
]);
});
});
describe("mapNeteaseArtists (artist search + detail)", () => {
it("maps cloudsearch type=100 artist entries", () => {
const out = mapNeteaseArtists([
{
id: 6452,
name: "Adele",
picUrl: "http://p/1.jpg",
alias: ["阿黛尔"],
musicSize: 120,
albumSize: 9,
},
]);
expect(out).toEqual([
{
id: "6452",
name: "Adele",
avatarUrl: "http://p/1.jpg",
aliases: ["阿黛尔"],
songCount: 120,
albumCount: 9,
platform: "netease",
},
]);
});
it("falls back to img1v1Url/alia and drops non-string or empty aliases", () => {
const out = mapNeteaseArtists([
{ id: 1, name: "X", img1v1Url: "http://p/2.jpg", alia: ["a", "", null, 3] },
]);
expect(out[0].avatarUrl).toBe("http://p/2.jpg");
expect(out[0].aliases).toEqual(["a"]);
expect(out[0].songCount).toBeUndefined();
expect(out[0].albumCount).toBeUndefined();
});
it("returns [] for empty/null input", () => {
expect(mapNeteaseArtists([])).toEqual([]);
expect(mapNeteaseArtists(null as any)).toEqual([]);
expect(mapNeteaseArtists(undefined as any)).toEqual([]);
});
});
describe("NeteaseProvider per-user login (#164)", () => {
@@ -184,3 +260,57 @@ describe("NeteaseProvider per-user login (#164)", () => {
expect(personal.platform).toBe("netease");
});
});
describe("NeteaseProvider.getArtistAllSongs (全部歌曲 paging)", () => {
const rawSongs = [
{ id: 1, name: "A", artists: [{ name: "X" }], album: { name: "Al" }, duration: 200000, fee: 0 },
{ id: 2, name: "B", artists: [{ name: "X" }], album: { name: "Al" }, duration: 100000, fee: 0 },
];
function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) {
const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) }));
(p as any).api = { get };
return get;
}
it("pages /artist/songs with order=hot and reports total/hasMore", async () => {
const p = new NeteaseProvider("http://x");
const get = withGet(p, () => ({ songs: rawSongs, total: 345, more: true }));
const page = await p.getArtistAllSongs("46487", 50, 50);
expect(get).toHaveBeenCalledTimes(1);
expect(get.mock.calls[0][0]).toBe("/artist/songs");
expect(get.mock.calls[0][1].params).toMatchObject({
id: "46487",
limit: 50,
offset: 50,
order: "hot",
});
expect(page.songs.map((s) => s.id)).toEqual(["1", "2"]);
expect(page.total).toBe(345);
expect(page.hasMore).toBe(true);
});
it("derives hasMore from total when the upstream omits `more`", async () => {
const p = new NeteaseProvider("http://x");
withGet(p, (_path, cfg) => ({
songs: rawSongs.slice(cfg.params.offset, cfg.params.offset + cfg.params.limit),
total: 2,
}));
expect((await p.getArtistAllSongs("1", 0, 1)).hasMore).toBe(true);
expect((await p.getArtistAllSongs("1", 1, 1)).hasMore).toBe(false);
});
it("clamps limit to 100, offset to >= 0, and derives a total when absent", async () => {
const p = new NeteaseProvider("http://x");
const get = withGet(p, () => ({ songs: rawSongs }));
const page = await p.getArtistAllSongs("1", -5, 500);
expect(get.mock.calls[0][1].params).toMatchObject({ limit: 100, offset: 0 });
expect(page.total).toBe(2);
expect(page.hasMore).toBe(false);
});
});
+88 -2
View File
@@ -10,6 +10,9 @@ import type {
QrCodeResult,
AuthStatus,
Album,
Artist,
ArtistDetail,
ArtistSongPage,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -69,6 +72,21 @@ export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
}));
}
export function mapNeteaseArtists(raw: any[] | null | undefined): Artist[] {
if (!Array.isArray(raw)) return [];
return raw.map((a: any) => ({
id: String(a.id),
name: a.name ?? "",
avatarUrl: a.picUrl ?? a.img1v1Url ?? "",
aliases: (a.alias ?? a.alia ?? []).filter(
(x: unknown): x is string => typeof x === "string" && x.length > 0
),
songCount: a.musicSize ?? undefined,
albumCount: a.albumSize ?? undefined,
platform: "netease",
}));
}
export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s: any) => ({
@@ -137,7 +155,7 @@ export class NeteaseProvider implements MusicProvider {
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
// and albums (type 10) are all limit/offset-driven so the web can page past
// the first page (playlists/albums were previously hardcoded to limit: 10).
const [songRes, playlistRes, albumRes] = await Promise.all([
const [songRes, playlistRes, albumRes, artistRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
}),
@@ -153,6 +171,9 @@ export class NeteaseProvider implements MusicProvider {
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 100, limit, offset, ...this.cookieParams },
}),
]);
const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs);
@@ -169,7 +190,9 @@ export class NeteaseProvider implements MusicProvider {
const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
return { songs, playlists, albums };
const artists = mapNeteaseArtists(artistRes.data?.result?.artists);
return { songs, playlists, albums, artists };
}
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
@@ -217,6 +240,69 @@ export class NeteaseProvider implements MusicProvider {
return mapNeteaseSongs(res.data?.songs);
}
async getArtistDetail(artistId: string): Promise<ArtistDetail | null> {
// /artists returns { artist, hotSongs }; the hot songs are fetched
// separately via /artist/songs (order=hot) so the artist page's three
// upstream calls stay independent of each other.
const res = await this.api.get("/artists", {
params: { id: artistId, ...this.cookieParams },
});
const a = res.data?.artist;
if (!a) return null;
return {
...mapNeteaseArtists([a])[0],
description: a.briefDesc ?? "",
};
}
async getArtistSongs(artistId: string, limit = 50): Promise<Song[]> {
const res = await this.api.get("/artist/songs", {
params: {
id: artistId,
limit,
offset: 0,
order: "hot",
...this.cookieParams,
},
});
return mapNeteaseSongs(res.data?.songs);
}
async getArtistAlbums(artistId: string, limit = 20): Promise<Album[]> {
const res = await this.api.get("/artist/album", {
params: { id: artistId, limit, offset: 0, ...this.cookieParams },
});
return mapNeteaseAlbums(res.data?.hotAlbums);
}
/**
* Full catalogue page for the artist page's "全部歌曲" list: /artist/songs
* supports real offset paging (Adele reports total 345 with more=true, and
* offset=50/100/150 each return a fresh slice of 50). order=hot keeps the page
* ordering identical to getArtistSongs so the hot preview and the full list
* are one continuous ranking.
*/
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
const safeOffset = Math.max(0, Math.trunc(offset) || 0);
const safeLimit = Math.max(1, Math.min(Math.trunc(limit) || 50, 100));
const res = await this.api.get("/artist/songs", {
params: {
id: artistId,
limit: safeLimit,
offset: safeOffset,
order: "hot",
...this.cookieParams,
},
});
const songs = mapNeteaseSongs(res.data?.songs);
const reported = Number(res.data?.total);
const total = Number.isFinite(reported) && reported > 0 ? reported : safeOffset + songs.length;
const more = res.data?.more;
const hasMore =
typeof more === "boolean" ? more : safeOffset + songs.length < total;
return { songs, total, hasMore };
}
async getLyrics(songId: string): Promise<LyricLine[]> {
const res = await this.api.get("/lyric", {
params: { id: songId, ...this.cookieParams },
+42
View File
@@ -59,6 +59,34 @@ export interface Album {
platform: Platform;
}
/** An artist / singer entity. Only sources with a real artist concept expose
* these (NetEase, QQ); the others simply never return `SearchResult.artists`
* and leave the optional provider methods unimplemented. */
export interface Artist {
id: string;
name: string;
avatarUrl: string;
platform: Platform;
/** Alternate names / romanizations (NetEase alias, QQ other_name). */
aliases?: string[];
songCount?: number;
albumCount?: number;
}
export interface ArtistDetail extends Artist {
/** Short biography, when the source provides one. */
description?: string;
}
/** One page of an artist's COMPLETE catalogue (the "全部歌曲" list), as opposed
* to `getArtistSongs`, which only ever returns the hot top-N. */
export interface ArtistSongPage {
songs: Song[];
/** Total tracks the source reports for this artist (best effort). */
total: number;
hasMore: boolean;
}
export interface LyricLine {
time: number; // seconds
text: string;
@@ -69,6 +97,8 @@ export interface SearchResult {
songs: Song[];
playlists: Playlist[];
albums: Album[];
/** Present only for sources with an artist entity (NetEase, QQ). */
artists?: Artist[];
}
export interface QrCodeResult {
@@ -108,4 +138,16 @@ export interface MusicProvider {
getDailyRecommendSongs?(): Promise<Song[]>;
getUserPlaylists?(): Promise<Playlist[]>;
getPlaylistDetail?(playlistId: string): Promise<PlaylistDetail | null>;
getArtistDetail?(artistId: string): Promise<ArtistDetail | null>;
/** The artist's most popular tracks, best-first. */
getArtistSongs?(artistId: string, limit?: number): Promise<Song[]>;
/** One page of the artist's full catalogue, best-first. Sources that can only
* expose a fixed top-N list leave this unimplemented (the route then 501s and
* the web hides the "全部歌曲" section). */
getArtistAllSongs?(
artistId: string,
offset?: number,
limit?: number
): Promise<ArtistSongPage>;
getArtistAlbums?(artistId: string, limit?: number): Promise<Album[]>;
}
+303 -1
View File
@@ -8,7 +8,7 @@ vi.mock("axios", () => ({
default: { create: () => ({ get: mockGet, post: mockPost }) },
}));
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
import { mapQqAlbums, mapQqArtists, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -171,4 +171,306 @@ describe("QQMusicProvider.search pagination", () => {
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
expect(songCall![1].params.p).toBe(2);
});
it("adds the singer sub-request (search_type 1) to the same musicu batch", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("周杰伦", 20, 0);
const d = musicuReqData();
expect(d.req_artist.param.search_type).toBe(1);
expect(d.req_artist.param.num_per_page).toBe(20);
expect(d.req_artist.param.page_num).toBe(1);
});
it("returns singers even when the song list is empty (no client_search_cp fallback)", async () => {
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return {
data: {
req_0: { data: { body: { song: { list: [] } } } },
req_album: { data: { body: { album: { list: [] } } } },
req_playlist: { data: { body: { songlist: { list: [] } } } },
req_artist: {
data: { body: { singer: { list: [{ singerMID: "m1", singerName: "Adele", songNum: 88 }] } } },
},
},
};
}
return { data: {} };
});
const p = new QQMusicProvider("http://x");
const res = await p.search("Adele", 20, 0);
expect(res.songs).toEqual([]);
expect(res.artists).toEqual([
{
id: "m1",
name: "Adele",
avatarUrl: "https://y.gtimg.cn/music/photo_new/T001R500x500M000m1.jpg",
songCount: 88,
albumCount: undefined,
platform: "qq",
},
]);
expect(mockGet.mock.calls.some((c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp")).toBe(false);
});
});
describe("mapQqArtists (singer search + detail)", () => {
it("maps singer list entries and builds the 500px portrait from the MID", () => {
const out = mapQqArtists([
{
singerMID: "abc",
singerName: "周杰伦",
singerPic: "http://y.gtimg.cn/music/photo_new/T001R150x150M000abc_11.jpg",
songNum: 500,
albumNum: 30,
},
]);
expect(out).toEqual([
{
id: "abc",
name: "周杰伦",
avatarUrl: "https://y.gtimg.cn/music/photo_new/T001R500x500M000abc.jpg",
songCount: 500,
albumCount: 30,
platform: "qq",
},
]);
});
it("falls back to the given picture when no MID is present", () => {
const out = mapQqArtists([
{ singerID: 42, singerName: "Y", singerPic: "https://y.gtimg.cn/music/photo_new/x.jpg" },
]);
expect(out).toEqual([
{
id: "42",
name: "Y",
avatarUrl: "https://y.gtimg.cn/music/photo_new/x.jpg",
songCount: undefined,
albumCount: undefined,
platform: "qq",
},
]);
});
it("drops entries without an id or name and tolerates empty input", () => {
expect(mapQqArtists([{ singerName: "no id" }, { singerMID: "x" }])).toEqual([]);
expect(mapQqArtists([])).toEqual([]);
expect(mapQqArtists(null as any)).toEqual([]);
expect(mapQqArtists(undefined as any)).toEqual([]);
});
});
describe("QQMusicProvider.getArtistAllSongs (album aggregation)", () => {
beforeEach(() => {
mockGet.mockReset();
});
function songRaw(mid: string, title: string) {
return { mid, title, singer: [{ name: "Adele" }], album: { mid: "al1", name: "Album" }, interval: 200 };
}
/** singer detail (top 50) + album search pages + per-album song lists. */
function mockCatalogue(opts: {
hot?: any[];
albumSearch?: (page: number) => any[];
albumSongs?: Record<string, any[]>;
albumInfoFails?: boolean;
}) {
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/cgi-bin/musicu.fcg") {
const data = JSON.parse(cfg.params.data);
if (data.req_0) {
return {
data: {
req_0: {
data: {
singer_info: { mid: "m1", name: "Adele" },
total_song: 250,
songlist: opts.hot ?? [],
},
},
},
};
}
const page = data.req_album?.param?.page_num ?? 1;
return {
data: {
req_album: { data: { body: { album: { list: (opts.albumSearch ?? (() => []))(page) } } } },
},
};
}
if (url === "/getAlbumInfo") {
if (opts.albumInfoFails) throw new Error("album down");
return { data: { response: { data: { list: opts.albumSongs?.[cfg.params.albummid] ?? [] } } } };
}
return { data: {} };
});
}
it("merges the hot tracks with every album track, de-duplicated and paged", async () => {
mockCatalogue({
hot: [songRaw("s1", "Hot 1"), songRaw("s2", "Hot 2")],
albumSearch: () => [
{ albumMID: "al1", albumName: "A", singerMID: "m1" },
{ albumMID: "al2", albumName: "B", singerMID: "m1" },
{ albumMID: "other", albumName: "C", singerMID: "m9" },
],
albumSongs: {
al1: [songRaw("s1", "Hot 1"), songRaw("s3", "Album 1")],
al2: [songRaw("s4", "Album 2")],
},
});
const p = new QQMusicProvider("http://x");
const page = await p.getArtistAllSongs("m1", 0, 10);
expect(page.songs.map((s) => s.id)).toEqual(["s1", "s2", "s3", "s4"]);
expect(page.total).toBe(4);
expect(page.hasMore).toBe(false);
// The unrelated album (singerMID m9) is never fetched.
const albumCalls = mockGet.mock.calls.filter((c: any[]) => c[0] === "/getAlbumInfo");
expect(albumCalls.map((c: any[]) => c[1].params.albummid).sort()).toEqual(["al1", "al2"]);
// Page 2 of the album search returns the same list → nothing new → stop.
const searchPages = mockGet.mock.calls
.filter((c: any[]) => c[0] === "/cgi-bin/musicu.fcg")
.map((c: any[]) => JSON.parse(c[1].params.data).req_album?.param?.page_num)
.filter(Boolean);
expect(searchPages).toEqual([1, 2]);
});
it("slices pages with offset/limit and reports hasMore", async () => {
mockCatalogue({
hot: [songRaw("s1", "1"), songRaw("s2", "2"), songRaw("s3", "3")],
albumSearch: () => [],
});
const p = new QQMusicProvider("http://x");
const first = await p.getArtistAllSongs("m1", 0, 2);
expect(first.songs.map((s) => s.id)).toEqual(["s1", "s2"]);
expect(first.total).toBe(3);
expect(first.hasMore).toBe(true);
const second = await p.getArtistAllSongs("m1", 2, 2);
expect(second.songs.map((s) => s.id)).toEqual(["s3"]);
expect(second.hasMore).toBe(false);
});
it("caches the assembled catalogue (one upstream sweep per singer)", async () => {
mockCatalogue({
hot: [songRaw("s1", "1")],
albumSearch: () => [{ albumMID: "al1", albumName: "A", singerMID: "m1" }],
albumSongs: { al1: [songRaw("s9", "9")] },
});
const p = new QQMusicProvider("http://x");
await p.getArtistAllSongs("m1", 0, 50);
const callsAfterFirst = mockGet.mock.calls.length;
const page = await p.getArtistAllSongs("m1", 0, 50);
expect(mockGet.mock.calls.length).toBe(callsAfterFirst);
expect(page.songs.map((s) => s.id)).toEqual(["s1", "s9"]);
});
it("degrades to the hot list when album lookups fail", async () => {
mockCatalogue({
hot: [songRaw("s1", "1")],
albumSearch: () => [{ albumMID: "al1", albumName: "A", singerMID: "m1" }],
albumInfoFails: true,
});
const p = new QQMusicProvider("http://x");
const page = await p.getArtistAllSongs("m1", 0, 50);
expect(page.songs.map((s) => s.id)).toEqual(["s1"]);
expect(page.total).toBe(1);
});
it("retries a failed album search once before giving up", async () => {
let albumSearchCalls = 0;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/cgi-bin/musicu.fcg") {
const data = JSON.parse(cfg.params.data);
if (data.req_0) {
return {
data: {
req_0: {
data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("s1", "1")] },
},
},
};
}
albumSearchCalls++;
if (albumSearchCalls === 1) throw new Error("blip");
const list =
data.req_album.param.page_num === 1
? [{ albumMID: "al1", albumName: "A", singerMID: "m1" }]
: [];
return { data: { req_album: { data: { body: { album: { list } } } } } };
}
if (url === "/getAlbumInfo") {
return { data: { response: { data: { list: [songRaw("s9", "9")] } } } };
}
return { data: {} };
});
const p = new QQMusicProvider("http://x");
const page = await p.getArtistAllSongs("m1", 0, 50);
const page1Calls = mockGet.mock.calls.filter((c: any[]) => {
if (c[0] !== "/cgi-bin/musicu.fcg") return false;
return JSON.parse(c[1].params.data).req_album?.param?.page_num === 1;
}).length;
expect(page1Calls).toBe(2);
expect(page.songs.map((s) => s.id)).toEqual(["s1", "s9"]);
});
it("does not cache a catalogue degraded by a failed album search", async () => {
let albumSearchFails = true;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/cgi-bin/musicu.fcg") {
const data = JSON.parse(cfg.params.data);
if (data.req_0) {
return {
data: {
req_0: {
data: {
singer_info: { mid: "m1", name: "Adele" },
songlist: [songRaw("s1", "1")],
},
},
},
};
}
if (albumSearchFails) throw new Error("upstream hiccup");
return {
data: {
req_album: {
data: { body: { album: { list: [{ albumMID: "al1", albumName: "A", singerMID: "m1" }] } } },
},
},
};
}
if (url === "/getAlbumInfo") {
return { data: { response: { data: { list: [songRaw("s9", "9")] } } } };
}
return { data: {} };
});
const p = new QQMusicProvider("http://x");
// The album search fails twice (call + retry) → hot list only, and the
// degraded result must not be cached.
const degraded = await p.getArtistAllSongs("m1", 0, 50);
expect(degraded.songs.map((s) => s.id)).toEqual(["s1"]);
expect(degraded.total).toBe(1);
albumSearchFails = false;
const full = await p.getArtistAllSongs("m1", 0, 50);
expect(full.songs.map((s) => s.id)).toEqual(["s1", "s9"]);
expect(full.total).toBe(2);
});
});
+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 },
+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