Merge pull request #57 from ZHANGTIANYAO1/feat/album-search

feat(search): album section + album playback
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-05-07 20:50:02 +08:00
commit 9efa818bbb
10 files changed
+327 -43

No files matched your search

+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics } from "./netease.js";
import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,32 @@ describe("NetEase adapter", () => {
expect(lines[0].text).toBe("Hello world");
expect(lines[0].translation).toBe("你好世界");
});
it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
const raw = [
{
id: 42,
name: "Album A",
picUrl: "https://x/p.jpg",
artists: [{ name: "Artist X" }, { name: "Featured Y" }],
size: 12,
},
{
id: 99,
name: "Album B",
picUrl: "",
artists: [],
},
];
expect(mapNeteaseAlbums(raw)).toEqual([
{ id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
{ id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
]);
});
it("mapNeteaseAlbums returns [] for empty/null input", () => {
expect(mapNeteaseAlbums([])).toEqual([]);
expect(mapNeteaseAlbums(null as any)).toEqual([]);
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
});
});
+20 -2
View File
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
return lines.sort((a, b) => a.time - b.time);
}
export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => ({
id: String(a.id),
name: a.name ?? "",
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
coverUrl: a.picUrl ?? "",
songCount: a.size ?? 0,
platform: "netease",
}));
}
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
export const NETEASE_QUALITY_LEVELS = [
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const [songRes, playlistRes] = await Promise.all([
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
@@ -103,6 +116,9 @@ export class NeteaseProvider implements MusicProvider {
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
}),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider {
platform: "netease",
}));
return { songs, playlists, albums: [] };
const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqAlbums maps albumMID-style raw entries", () => {
const raw = [
{
albumMID: "abc",
albumName: "Aero",
singerName: "Singer A",
},
{
albumMID: "xyz",
albumName: "Beta",
singer: [{ name: "Singer B" }, { name: "Singer C" }],
},
];
const out = mapQqAlbums(raw);
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
id: "abc",
name: "Aero",
artist: "Singer A",
platform: "qq",
});
expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
expect(out[1].artist).toBe("Singer B / Singer C");
expect(out[1].coverUrl).toContain("xyz");
});
it("mapQqAlbums returns [] for empty/null input", () => {
expect(mapQqAlbums([])).toEqual([]);
expect(mapQqAlbums(null as any)).toEqual([]);
expect(mapQqAlbums(undefined as any)).toEqual([]);
});
it("mapQqAlbums falls back to albumPic when no albumMID", () => {
const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
const out = mapQqAlbums(raw);
expect(out[0].coverUrl).toBe("https://x/p.jpg");
expect(out[0].id).toBe("");
});
});
+31 -6
View File
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
@@ -27,6 +28,26 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => {
const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
const artist = a.singerName
?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
const coverUrl = id
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
: (a.albumPic ?? "");
return {
id,
name: a.albumName ?? a.title ?? "",
artist,
coverUrl,
songCount: a.song_count ?? a.songCount ?? 0,
platform: "qq" as const,
};
});
}
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -65,11 +86,12 @@ export class QQMusicProvider implements MusicProvider {
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: {
searchid: "1",
query,
num_per_page: Math.min(limit, 50),
},
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
@@ -90,7 +112,10 @@ export class QQMusicProvider implements MusicProvider {
platform: "qq",
}));
return { songs, playlists: [], albums: [] };
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
+10 -4
View File
@@ -52,14 +52,20 @@ export function createMusicRouter(
]);
const songs = [
...(neteaseResult.status === "fulfilled"
? neteaseResult.value.songs
: []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
];
const playlists = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
res.json({ songs });
res.json({ songs, albums, playlists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
+72
View File
@@ -313,6 +313,78 @@ export function createPlayerRouter(
}
});
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
router.post("/:botId/play-album", async (req, res) => {
try {
const bot = (req as any).bot;
const { albumId, platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
// Stop current playback
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) {
res.json({ message: "Album is empty" });
return;
}
// QQ-specific optimization: batch-resolve playable IDs to avoid
// wasting retries on region/copyright-restricted tracks.
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 });
}
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-album failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
try {