mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-09 00:12: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
+131
-1
@@ -1,5 +1,12 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
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", () => {
|
describe("NetEase adapter", () => {
|
||||||
it("parses LRC format lyrics", () => {
|
it("parses LRC format lyrics", () => {
|
||||||
@@ -138,6 +145,75 @@ describe("NeteaseProvider.search pagination", () => {
|
|||||||
expect(callByType(get, 1000).offset).toBe(0);
|
expect(callByType(get, 1000).offset).toBe(0);
|
||||||
expect(callByType(get, 10).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)", () => {
|
describe("NeteaseProvider per-user login (#164)", () => {
|
||||||
@@ -184,3 +260,57 @@ describe("NeteaseProvider per-user login (#164)", () => {
|
|||||||
expect(personal.platform).toBe("netease");
|
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
@@ -10,6 +10,9 @@ import type {
|
|||||||
QrCodeResult,
|
QrCodeResult,
|
||||||
AuthStatus,
|
AuthStatus,
|
||||||
Album,
|
Album,
|
||||||
|
Artist,
|
||||||
|
ArtistDetail,
|
||||||
|
ArtistSongPage,
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
|
|
||||||
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
|
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[] {
|
export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.map((s: any) => ({
|
return raw.map((s: any) => ({
|
||||||
@@ -137,7 +155,7 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
|
// /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
|
// 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).
|
// 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", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
|
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
@@ -153,6 +171,9 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
|
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);
|
const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs);
|
||||||
@@ -169,7 +190,9 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
|
|
||||||
const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
|
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> {
|
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||||
@@ -217,6 +240,69 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
return mapNeteaseSongs(res.data?.songs);
|
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[]> {
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
const res = await this.api.get("/lyric", {
|
const res = await this.api.get("/lyric", {
|
||||||
params: { id: songId, ...this.cookieParams },
|
params: { id: songId, ...this.cookieParams },
|
||||||
|
|||||||
@@ -59,6 +59,34 @@ export interface Album {
|
|||||||
platform: Platform;
|
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 {
|
export interface LyricLine {
|
||||||
time: number; // seconds
|
time: number; // seconds
|
||||||
text: string;
|
text: string;
|
||||||
@@ -69,6 +97,8 @@ export interface SearchResult {
|
|||||||
songs: Song[];
|
songs: Song[];
|
||||||
playlists: Playlist[];
|
playlists: Playlist[];
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
|
/** Present only for sources with an artist entity (NetEase, QQ). */
|
||||||
|
artists?: Artist[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QrCodeResult {
|
export interface QrCodeResult {
|
||||||
@@ -108,4 +138,16 @@ export interface MusicProvider {
|
|||||||
getDailyRecommendSongs?(): Promise<Song[]>;
|
getDailyRecommendSongs?(): Promise<Song[]>;
|
||||||
getUserPlaylists?(): Promise<Playlist[]>;
|
getUserPlaylists?(): Promise<Playlist[]>;
|
||||||
getPlaylistDetail?(playlistId: string): Promise<PlaylistDetail | null>;
|
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
@@ -8,7 +8,7 @@ vi.mock("axios", () => ({
|
|||||||
default: { create: () => ({ get: mockGet, post: mockPost }) },
|
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", () => {
|
describe("QQ adapter", () => {
|
||||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
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, "expected a client_search_cp song call").toBeTruthy();
|
||||||
expect(songCall![1].params.p).toBe(2);
|
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
@@ -10,6 +10,9 @@ import type {
|
|||||||
QrCodeResult,
|
QrCodeResult,
|
||||||
AuthStatus,
|
AuthStatus,
|
||||||
Album,
|
Album,
|
||||||
|
Artist,
|
||||||
|
ArtistDetail,
|
||||||
|
ArtistSongPage,
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { parseLyrics } from "./netease.js";
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
@@ -40,6 +43,28 @@ const qqFavApi = axios.create({
|
|||||||
headers: { referer: "https://y.qq.com/" },
|
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[] {
|
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.map((s) => {
|
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 {
|
function computeGtk(pSkey: string): number {
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
for (let i = 0; i < pSkey.length; i++) {
|
for (let i = 0; i < pSkey.length; i++) {
|
||||||
@@ -195,6 +247,13 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
|
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", {
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||||
params: { format: "json", data: reqData },
|
params: { format: "json", data: reqData },
|
||||||
@@ -202,7 +261,11 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
|
|
||||||
const songList: any[] =
|
const songList: any[] =
|
||||||
res.data?.req_0?.data?.body?.song?.list ?? [];
|
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);
|
const songs = mapQqSongs(songList);
|
||||||
|
|
||||||
@@ -218,7 +281,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
platform: "qq" as const,
|
platform: "qq" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { songs, playlists, albums };
|
return { songs, playlists, albums, artists: mapQqArtists(artistList) };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -470,6 +533,196 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
return mapQqSongs(res.data?.response?.data?.list ?? []);
|
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[]> {
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
const res = await this.api.get("/getLyric", {
|
const res = await this.api.get("/getLyric", {
|
||||||
params: { songmid: songId, ...this.cookieParams },
|
params: { songmid: songId, ...this.cookieParams },
|
||||||
|
|||||||
@@ -503,3 +503,117 @@ describe("music router GET /bilibili/parts", () => {
|
|||||||
expect(res.status).toBe(404);
|
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 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 { YouTubeProvider } from "../../music/youtube.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.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
|
// searched. Jellyfin (an opt-in source) leads the merged results when
|
||||||
// enabled — a self-hosted library match is almost always the wanted one.
|
// enabled — a self-hosted library match is almost always the wanted one.
|
||||||
const enabled = (p: string) => !config || isProviderEnabled(config, p);
|
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([
|
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||||
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||||
enabled("netease") ? neteaseProvider.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 : []),
|
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
|
||||||
...(qqResult.status === "fulfilled" ? qqResult.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) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Unified search failed");
|
logger.error({ err }, "Unified search failed");
|
||||||
res.status(500).json({ error: (err as Error).message });
|
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) => {
|
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = resolveProvider(req.query.platform, res);
|
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);
|
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 allow = mountGuest({ playCollection: true });
|
||||||
const deny = mountGuest({ playCollection: false });
|
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-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-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-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-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.
|
// 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`).send({ query: "x" })).status).toBe(403);
|
||||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).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 { Router } from "express";
|
||||||
import type { BotManager } from "../../bot/manager.js";
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
import type { BotDatabase } from "../../data/database.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 type { Logger } from "../../logger.js";
|
||||||
import { parseCommand } from "../../bot/commands.js";
|
import { parseCommand } from "../../bot/commands.js";
|
||||||
import { requireBotAccess } from "../middleware/requirePermission.js";
|
import { requireBotAccess } from "../middleware/requirePermission.js";
|
||||||
import { authorize } from "../middleware/authorize.js";
|
import { authorize } from "../middleware/authorize.js";
|
||||||
import { supportsPersonalLogin } from "./personal-music.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(
|
export function createPlayerRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
logger: Logger,
|
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
|
// Play a single song by ID — resolves URL on demand. Funnels through
|
||||||
// bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs
|
// bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs
|
||||||
// insert-and-jump, keeping the queue) lives in one place shared with chat
|
// insert-and-jump, keeping the queue) lives in one place shared with chat
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const router = createRouter({
|
|||||||
component: () => import('../views/Playlist.vue'),
|
component: () => import('../views/Playlist.vue'),
|
||||||
meta: { kind: 'album' },
|
meta: { kind: 'album' },
|
||||||
},
|
},
|
||||||
|
{ path: '/artist/:id', name: 'artist', component: () => import('../views/Artist.vue') },
|
||||||
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
|
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
|
||||||
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
|
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
|
||||||
{ path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') },
|
{ path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') },
|
||||||
|
|||||||
@@ -583,6 +583,37 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue an artist's songs. The server always loads the singer's FULL
|
||||||
|
* catalogue (never just the hot 50), which costs it a few upstream round
|
||||||
|
* trips, so the caller gets a "loading" notice first.
|
||||||
|
*/
|
||||||
|
async playArtist(artistId: string, platform = 'netease') {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
this.notify('正在载入该歌手的全部歌曲…', 'info');
|
||||||
|
try {
|
||||||
|
const res = await axios.post(
|
||||||
|
`/api/player/${this.activeBotId}/play-artist`,
|
||||||
|
{ artistId, platform },
|
||||||
|
);
|
||||||
|
if (res.data?.message) {
|
||||||
|
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||||
|
}
|
||||||
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
|
this._syncAfterAction();
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.response?.status;
|
||||||
|
this.notify(
|
||||||
|
status === 403
|
||||||
|
? '没有权限播放整个歌手'
|
||||||
|
: status === 501
|
||||||
|
? '该音源不支持播放歌手歌曲'
|
||||||
|
: '播放歌手失败',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async pause() {
|
async pause() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
// Freeze elapsed at the current LIVE interpolated value. Using the cached
|
// Freeze elapsed at the current LIVE interpolated value. Using the cached
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = () => readFileSync(join(currentDir, "Artist.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("Artist page layout", () => {
|
||||||
|
it("has no 全部歌曲 browser section (play buttons queue the catalogue instead)", () => {
|
||||||
|
const src = source();
|
||||||
|
|
||||||
|
expect(src).not.toContain("全部歌曲</h2>");
|
||||||
|
expect(src).not.toContain("加载更多");
|
||||||
|
expect(src).not.toContain("加载全部歌曲");
|
||||||
|
expect(src).not.toContain("ALL_SONGS_PAGE");
|
||||||
|
expect(src).not.toContain("loadMoreSongs");
|
||||||
|
expect(src).not.toContain("allSongs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the hot songs and album sections", () => {
|
||||||
|
const src = source();
|
||||||
|
|
||||||
|
expect(src).toContain("热门歌曲");
|
||||||
|
expect(src).toContain("专辑");
|
||||||
|
expect(src).toContain("显示全部");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays through playArtist without an all flag (the server always loads everything)", () => {
|
||||||
|
const src = source();
|
||||||
|
|
||||||
|
expect(src).toContain("store.playArtist(artistId(), platform.value)");
|
||||||
|
expect(src).not.toContain("playArtist(artistId(), platform.value, true)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches when navigating between artists", () => {
|
||||||
|
const src = source();
|
||||||
|
|
||||||
|
expect(src).toContain("watch(");
|
||||||
|
expect(src).toContain("route.params.id");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
<template>
|
||||||
|
<div class="artist-page">
|
||||||
|
<button class="back-btn" @click="$router.back()">
|
||||||
|
<Icon icon="mdi:arrow-left" />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
|
||||||
|
<template v-else-if="artist">
|
||||||
|
<!-- Hero: Apple Music style — round portrait, then name / meta / actions. -->
|
||||||
|
<div class="artist-hero">
|
||||||
|
<div class="artist-portrait-wrap">
|
||||||
|
<img
|
||||||
|
v-if="artist.avatarUrl"
|
||||||
|
class="artist-portrait"
|
||||||
|
:src="artist.avatarUrl"
|
||||||
|
:alt="artist.name"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<div v-else class="artist-portrait artist-portrait-fallback">
|
||||||
|
<Icon icon="mdi:account-music" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="artist-meta">
|
||||||
|
<div class="artist-platform">{{ platformLabel }}</div>
|
||||||
|
<h1 class="artist-title">{{ artist.name }}</h1>
|
||||||
|
<div v-if="artist.aliases?.length" class="artist-aliases">
|
||||||
|
{{ artist.aliases.join(' / ') }}
|
||||||
|
</div>
|
||||||
|
<div class="artist-stats">
|
||||||
|
<span v-if="hotSongs.length">热门歌曲 {{ hotSongs.length }} 首</span>
|
||||||
|
<span v-if="albums.length">专辑 {{ albums.length }} 张</span>
|
||||||
|
<span v-if="artist.songCount">共 {{ artist.songCount }} 首歌</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="artist.description" class="artist-desc">{{ artist.description }}</p>
|
||||||
|
<div class="artist-actions">
|
||||||
|
<button
|
||||||
|
v-if="canPlayAll"
|
||||||
|
class="play-all-btn"
|
||||||
|
:disabled="!artist"
|
||||||
|
title="播放该歌手的全部歌曲"
|
||||||
|
@click="playAll"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:play" />
|
||||||
|
播放
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canPlayAll"
|
||||||
|
class="shuffle-btn"
|
||||||
|
:disabled="!artist"
|
||||||
|
title="随机播放该歌手的全部歌曲"
|
||||||
|
@click="shuffleAll"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:shuffle" />
|
||||||
|
随机播放
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="hotSongs.length" class="artist-section">
|
||||||
|
<h2 class="section-title">热门歌曲</h2>
|
||||||
|
<div class="song-list">
|
||||||
|
<SongCard
|
||||||
|
v-for="(song, i) in visibleSongs"
|
||||||
|
:key="song.id"
|
||||||
|
:song="song"
|
||||||
|
:index="i + 1"
|
||||||
|
:active="store.currentSong?.id === song.id"
|
||||||
|
@play="store.playSong(song)"
|
||||||
|
@playNext="store.playNextSong(song)"
|
||||||
|
@add="store.addSong(song)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button v-if="hotSongs.length > visibleCount" class="more-btn" @click="expanded = true">
|
||||||
|
显示全部 {{ hotSongs.length }} 首
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="albums.length" class="artist-section">
|
||||||
|
<h2 class="section-title">专辑</h2>
|
||||||
|
<div class="album-row">
|
||||||
|
<router-link
|
||||||
|
v-for="al in albums"
|
||||||
|
:key="al.id"
|
||||||
|
:to="`/album/${al.id}?platform=${al.platform}`"
|
||||||
|
class="album-card hover-scale"
|
||||||
|
>
|
||||||
|
<CoverArt :url="al.coverUrl" :size="150" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="album-name">{{ al.name }}</div>
|
||||||
|
<div v-if="al.songCount" class="album-sub">{{ al.songCount }} 首</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="loading">歌手不存在或加载失败</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePlayerStore, type Song } from '../stores/player.js';
|
||||||
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
import SongCard from '../components/SongCard.vue';
|
||||||
|
|
||||||
|
interface ArtistDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
aliases?: string[];
|
||||||
|
songCount?: number;
|
||||||
|
albumCount?: number;
|
||||||
|
platform: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Album {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount?: number;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hot songs shown before the "显示全部" expander (the API returns up to 50). */
|
||||||
|
const HOT_SONG_PREVIEW = 10;
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const { can, guestCan } = useSession();
|
||||||
|
|
||||||
|
// Same permission as "play all" on a playlist: members need player.control,
|
||||||
|
// guests need the playCollection flag.
|
||||||
|
const canPlayAll = computed(() => can('player.control') || guestCan('playCollection'));
|
||||||
|
|
||||||
|
const artist = ref<ArtistDetail | null>(null);
|
||||||
|
const hotSongs = ref<Song[]>([]);
|
||||||
|
const albums = ref<Album[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
const platform = computed(() => (route.query.platform as string) || 'netease');
|
||||||
|
const platformLabel = computed(() => (platform.value === 'qq' ? 'QQ 音乐' : '网易云音乐'));
|
||||||
|
|
||||||
|
const visibleCount = computed(() =>
|
||||||
|
expanded.value ? hotSongs.value.length : Math.min(HOT_SONG_PREVIEW, hotSongs.value.length),
|
||||||
|
);
|
||||||
|
const visibleSongs = computed(() => hotSongs.value.slice(0, visibleCount.value));
|
||||||
|
|
||||||
|
function artistId(): string {
|
||||||
|
return route.params.id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playAll() {
|
||||||
|
// The server queues the singer's whole catalogue, not just the hot 50.
|
||||||
|
await store.playArtist(artistId(), platform.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shuffleAll() {
|
||||||
|
// Shuffle reuses the queue's own random mode (the same switch the player
|
||||||
|
// toolbar exposes), so the play button and the mode badge stay consistent.
|
||||||
|
await store.setMode('random');
|
||||||
|
await store.playArtist(artistId(), platform.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArtist() {
|
||||||
|
loading.value = true;
|
||||||
|
// Reset every per-artist piece: RouterView reuses this component when only the
|
||||||
|
// route params change, so artist → artist navigation must not show stale rows.
|
||||||
|
artist.value = null;
|
||||||
|
hotSongs.value = [];
|
||||||
|
albums.value = [];
|
||||||
|
expanded.value = false;
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/music/artist/${artistId()}`, {
|
||||||
|
params: { platform: platform.value },
|
||||||
|
});
|
||||||
|
artist.value = res.data?.artist ?? null;
|
||||||
|
hotSongs.value = res.data?.songs ?? [];
|
||||||
|
albums.value = res.data?.albums ?? [];
|
||||||
|
} catch {
|
||||||
|
artist.value = null;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadArtist);
|
||||||
|
watch(() => `${route.params.id}|${route.query.platform ?? ''}`, loadArtist);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-portrait-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-portrait {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-portrait-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 72px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-platform {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-aliases {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-width: 640px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-all-btn,
|
||||||
|
.shuffle-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform var(--transition-fast), background var(--transition-fast);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-all-btn {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
&:not(:disabled):hover { transform: scale(1.04); }
|
||||||
|
&:not(:disabled):active { transform: scale(0.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shuffle-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-name {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.artist-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-portrait {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+302
-3
@@ -12,9 +12,35 @@
|
|||||||
v-model="query"
|
v-model="query"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="搜索歌曲、歌手、专辑..."
|
placeholder="搜索歌曲、歌手、专辑..."
|
||||||
|
@focus="historyOpen = true"
|
||||||
|
@blur="historyOpen = false"
|
||||||
@keyup.enter="doSearch"
|
@keyup.enter="doSearch"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
|
<button v-if="query" class="search-clear" @click="query = ''">
|
||||||
|
<Icon icon="mdi:close-circle" />
|
||||||
|
</button>
|
||||||
|
<!-- Recent searches for this browser. mousedown.prevent keeps the input
|
||||||
|
focused so blur doesn't close the list before the click lands. -->
|
||||||
|
<div v-if="historyOpen && historyEntries.length" class="history-dropdown">
|
||||||
|
<div class="history-dropdown-head">
|
||||||
|
<span>搜索历史</span>
|
||||||
|
<button class="history-clear-btn" @mousedown.prevent @click="clearAllHistory">清空</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="h in historyEntries"
|
||||||
|
:key="`${h.platform}:${h.q}`"
|
||||||
|
class="history-dropdown-item"
|
||||||
|
@mousedown.prevent="applyHistory(h)"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:history" class="history-item-icon" />
|
||||||
|
<span class="history-item-query">{{ h.q }}</span>
|
||||||
|
<span class="platform-badge" :class="badgeClass(h.platform)">{{ badgeLabel(h.platform) }}</span>
|
||||||
|
<button class="history-item-remove" @mousedown.prevent.stop="removeHistoryEntry(h)">
|
||||||
|
<Icon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -55,7 +81,7 @@
|
|||||||
|
|
||||||
<div v-if="loading" class="loading">搜索中...</div>
|
<div v-if="loading" class="loading">搜索中...</div>
|
||||||
|
|
||||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length || allArtists.length">
|
||||||
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
|
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
|
||||||
(opt-in) comes first when enabled. -->
|
(opt-in) comes first when enabled. -->
|
||||||
<div class="source-bar">
|
<div class="source-bar">
|
||||||
@@ -97,6 +123,31 @@
|
|||||||
>本地</button>
|
>本地</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Artist row: inline above the tabs, Apple Music style. Only netease/qq
|
||||||
|
model artists, so the other sources have nothing to show here. -->
|
||||||
|
<section v-if="filteredArtists.length" class="artist-row">
|
||||||
|
<router-link
|
||||||
|
v-for="ar in filteredArtists"
|
||||||
|
:key="`${ar.platform}-${ar.id}`"
|
||||||
|
:to="`/artist/${ar.id}?platform=${ar.platform}`"
|
||||||
|
class="artist-card"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="ar.avatarUrl"
|
||||||
|
class="artist-avatar"
|
||||||
|
:src="ar.avatarUrl"
|
||||||
|
:alt="ar.name"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<div v-else class="artist-avatar artist-avatar-fallback">
|
||||||
|
<Icon icon="mdi:account-music" />
|
||||||
|
</div>
|
||||||
|
<div class="artist-card-name">{{ ar.name }}</div>
|
||||||
|
<div v-if="ar.songCount" class="artist-card-sub">{{ ar.songCount }} 首歌</div>
|
||||||
|
</router-link>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
@@ -186,7 +237,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
<div v-else-if="!searched" class="search-intro">
|
||||||
|
<template v-if="historyEntries.length">
|
||||||
|
<div class="history-tags-title">最近搜索</div>
|
||||||
|
<div class="history-tags">
|
||||||
|
<button
|
||||||
|
v-for="h in historyEntries"
|
||||||
|
:key="`tag-${h.platform}:${h.q}`"
|
||||||
|
class="history-tag"
|
||||||
|
@click="applyHistory(h)"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:history" />
|
||||||
|
{{ h.q }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="search-intro-text">搜索歌曲、歌手或专辑,歌手支持专属页面</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty">未找到相关结果</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -200,6 +269,14 @@ import type { Song } from '../stores/player.js';
|
|||||||
import SongCard from '../components/SongCard.vue';
|
import SongCard from '../components/SongCard.vue';
|
||||||
import CoverArt from '../components/CoverArt.vue';
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||||
|
import {
|
||||||
|
clearStoredHistory,
|
||||||
|
loadHistory,
|
||||||
|
pushHistory,
|
||||||
|
removeHistory,
|
||||||
|
saveHistory,
|
||||||
|
type SearchHistoryEntry,
|
||||||
|
} from './searchHistory.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -229,11 +306,17 @@ const selectedSource = ref<SearchSource>(loadSource());
|
|||||||
|
|
||||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||||
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||||
|
interface Artist { id: string; name: string; avatarUrl: string; platform: string; songCount?: number; albumCount?: number; }
|
||||||
|
|
||||||
const allSongs = ref<Song[]>([]);
|
const allSongs = ref<Song[]>([]);
|
||||||
const allAlbums = ref<Album[]>([]);
|
const allAlbums = ref<Album[]>([]);
|
||||||
const allPlaylists = ref<Playlist[]>([]);
|
const allPlaylists = ref<Playlist[]>([]);
|
||||||
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
|
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
|
||||||
|
const allArtists = ref<Artist[]>([]);
|
||||||
|
// Search history lives in localStorage (see searchHistory.ts) — loaded on mount
|
||||||
|
// so SSR/blocked-storage environments simply start empty.
|
||||||
|
const historyEntries = ref<SearchHistoryEntry[]>([]);
|
||||||
|
const historyOpen = ref(false);
|
||||||
const hasMoreMap = ref<Record<string, boolean>>({});
|
const hasMoreMap = ref<Record<string, boolean>>({});
|
||||||
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
|
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -257,6 +340,10 @@ const filteredPlaylists = computed(() =>
|
|||||||
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredArtists = computed(() =>
|
||||||
|
allArtists.value.filter((a) => a.platform === selectedSource.value)
|
||||||
|
);
|
||||||
|
|
||||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||||
|
|
||||||
// Server-side source gate (enabledProviders). Until /providers loads, the
|
// Server-side source gate (enabledProviders). Until /providers loads, the
|
||||||
@@ -383,6 +470,7 @@ async function toggleFavPlaylist(pl: { id: string; platform: string; name: strin
|
|||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
if (!query.value.trim()) return;
|
if (!query.value.trim()) return;
|
||||||
|
recordHistory();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
activeTab.value = 'songs';
|
activeTab.value = 'songs';
|
||||||
@@ -393,16 +481,43 @@ async function doSearch() {
|
|||||||
allSongs.value = res.data.songs ?? [];
|
allSongs.value = res.data.songs ?? [];
|
||||||
allAlbums.value = res.data.albums ?? [];
|
allAlbums.value = res.data.albums ?? [];
|
||||||
allPlaylists.value = res.data.playlists ?? [];
|
allPlaylists.value = res.data.playlists ?? [];
|
||||||
|
allArtists.value = res.data.artists ?? [];
|
||||||
recordInitialHasMore(allSongs.value, 'songs');
|
recordInitialHasMore(allSongs.value, 'songs');
|
||||||
recordInitialHasMore(allAlbums.value, 'albums');
|
recordInitialHasMore(allAlbums.value, 'albums');
|
||||||
recordInitialHasMore(allPlaylists.value, 'playlists');
|
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||||
} catch {
|
} catch {
|
||||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allArtists.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 搜索历史 ----
|
||||||
|
function recordHistory() {
|
||||||
|
historyEntries.value = pushHistory(historyEntries.value, query.value, selectedSource.value);
|
||||||
|
saveHistory(historyEntries.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-run a stored search, restoring the source it was made from. */
|
||||||
|
function applyHistory(entry: SearchHistoryEntry) {
|
||||||
|
historyOpen.value = false;
|
||||||
|
query.value = entry.q;
|
||||||
|
if (SEARCH_SOURCES.includes(entry.platform as SearchSource) && sourceEnabled(entry.platform)) {
|
||||||
|
selectedSource.value = entry.platform as SearchSource;
|
||||||
|
}
|
||||||
|
doSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeHistoryEntry(entry: SearchHistoryEntry) {
|
||||||
|
historyEntries.value = removeHistory(historyEntries.value, entry.q, entry.platform);
|
||||||
|
saveHistory(historyEntries.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllHistory() {
|
||||||
|
historyEntries.value = [];
|
||||||
|
clearStoredHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
|
/** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
|
||||||
const UPLOAD_MAX_MB = 500;
|
const UPLOAD_MAX_MB = 500;
|
||||||
@@ -546,6 +661,7 @@ async function loadLocalAudioSetting() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadLocalAudioSetting();
|
loadLocalAudioSetting();
|
||||||
|
historyEntries.value = loadHistory();
|
||||||
if (query.value) doSearch();
|
if (query.value) doSearch();
|
||||||
await store.fetchProviders();
|
await store.fetchProviders();
|
||||||
fixupSelectedSource();
|
fixupSelectedSource();
|
||||||
@@ -649,6 +765,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input-wrap {
|
.search-input-wrap {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
@@ -657,6 +774,188 @@ onMounted(async () => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% - 10px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dropdown-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 8px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-clear-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { color: var(--color-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover { background: var(--bg-card); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-query {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||||
|
|
||||||
|
.history-dropdown-item:hover & { opacity: 1; }
|
||||||
|
|
||||||
|
&:hover { color: #e74c3c; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-intro {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tags-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-intro-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 104px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-avatar {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-avatar-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 34px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card:hover .artist-avatar {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card-name {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
SEARCH_HISTORY_MAX,
|
||||||
|
SEARCH_HISTORY_STORAGE_KEY,
|
||||||
|
clearStoredHistory,
|
||||||
|
historyForPlatform,
|
||||||
|
loadHistory,
|
||||||
|
parseHistory,
|
||||||
|
pushHistory,
|
||||||
|
removeHistory,
|
||||||
|
sameEntry,
|
||||||
|
saveHistory,
|
||||||
|
serializeHistory,
|
||||||
|
type SearchHistoryEntry,
|
||||||
|
} from "./searchHistory.js";
|
||||||
|
|
||||||
|
function memoryStorage(seed: Record<string, string> = {}) {
|
||||||
|
const map = new Map(Object.entries(seed));
|
||||||
|
return {
|
||||||
|
getItem: (k: string) => map.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void map.set(k, v),
|
||||||
|
removeItem: (k: string) => void map.delete(k),
|
||||||
|
dump: () => Object.fromEntries(map),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = (q: string, platform = "netease", at = 1): SearchHistoryEntry => ({ q, platform, at });
|
||||||
|
|
||||||
|
describe("search history", () => {
|
||||||
|
let storage: ReturnType<typeof memoryStorage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = memoryStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pushHistory", () => {
|
||||||
|
it("prepends the newest query", () => {
|
||||||
|
const list = pushHistory([entry("old")], "new", "netease", 2);
|
||||||
|
expect(list.map((e) => e.q)).toEqual(["new", "old"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims the query and ignores blank input", () => {
|
||||||
|
expect(pushHistory([], " hello ", "netease", 2)[0].q).toBe("hello");
|
||||||
|
expect(pushHistory([entry("keep")], " ", "netease", 2)).toEqual([entry("keep")]);
|
||||||
|
expect(pushHistory([], "", "netease", 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a repeated query to the front instead of duplicating it", () => {
|
||||||
|
const list = pushHistory([entry("b", "netease", 2), entry("a", "netease", 1)], "a", "netease", 3);
|
||||||
|
expect(list.map((e) => e.q)).toEqual(["a", "b"]);
|
||||||
|
expect(list[0].at).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches repeated queries case-insensitively", () => {
|
||||||
|
const list = pushHistory([entry("Hello", "netease", 1)], "hello", "netease", 2);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].q).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same query from a different source as its own entry", () => {
|
||||||
|
const list = pushHistory([entry("hello", "netease", 1)], "hello", "qq", 2);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
expect(list.map((e) => e.platform)).toEqual(["qq", "netease"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the list at SEARCH_HISTORY_MAX, dropping the oldest", () => {
|
||||||
|
let list: SearchHistoryEntry[] = [];
|
||||||
|
for (let i = 1; i <= SEARCH_HISTORY_MAX + 3; i++) {
|
||||||
|
list = pushHistory(list, `q${i}`, "netease", i);
|
||||||
|
}
|
||||||
|
expect(list).toHaveLength(SEARCH_HISTORY_MAX);
|
||||||
|
expect(list[0].q).toBe(`q${SEARCH_HISTORY_MAX + 3}`);
|
||||||
|
expect(list.at(-1)?.q).toBe("q4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input list", () => {
|
||||||
|
const list = [entry("a")];
|
||||||
|
pushHistory(list, "b", "netease", 2);
|
||||||
|
expect(list.map((e) => e.q)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeHistory", () => {
|
||||||
|
it("removes only the matching query+source pair", () => {
|
||||||
|
const list = [entry("a", "netease"), entry("a", "qq"), entry("b", "netease")];
|
||||||
|
expect(removeHistory(list, "a", "netease").map((e) => `${e.platform}:${e.q}`)).toEqual([
|
||||||
|
"qq:a",
|
||||||
|
"netease:b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the query", () => {
|
||||||
|
expect(removeHistory([entry("Hello")], "hello", "netease")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("historyForPlatform", () => {
|
||||||
|
it("returns only the selected source, newest first", () => {
|
||||||
|
const list = [entry("a", "qq", 3), entry("b", "netease", 2), entry("c", "netease", 1)];
|
||||||
|
expect(historyForPlatform(list, "netease").map((e) => e.q)).toEqual(["b", "c"]);
|
||||||
|
expect(historyForPlatform(list, "bilibili")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parse/serialize", () => {
|
||||||
|
it("round-trips a list", () => {
|
||||||
|
const list = [entry("a"), entry("b", "qq", 5)];
|
||||||
|
expect(parseHistory(serializeHistory(list))).toEqual(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for malformed or non-array payloads", () => {
|
||||||
|
expect(parseHistory(null)).toEqual([]);
|
||||||
|
expect(parseHistory("")).toEqual([]);
|
||||||
|
expect(parseHistory("{not json")).toEqual([]);
|
||||||
|
expect(parseHistory('{"q":"a"}')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops entries that are missing required fields", () => {
|
||||||
|
const raw = JSON.stringify([
|
||||||
|
{ q: "ok", platform: "netease", at: 1 },
|
||||||
|
{ q: "", platform: "netease", at: 2 },
|
||||||
|
{ q: "no-platform", platform: "", at: 3 },
|
||||||
|
{ q: "no-time", platform: "qq" },
|
||||||
|
{ q: "not-an-object", platform: 5, at: 4 },
|
||||||
|
]);
|
||||||
|
expect(parseHistory(raw).map((e) => e.q)).toEqual(["ok"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates over-long stored payloads to the cap", () => {
|
||||||
|
const raw = JSON.stringify(
|
||||||
|
Array.from({ length: SEARCH_HISTORY_MAX + 5 }, (_, i) => entry(`q${i}`, "netease", i)),
|
||||||
|
);
|
||||||
|
expect(parseHistory(raw)).toHaveLength(SEARCH_HISTORY_MAX);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storage helpers", () => {
|
||||||
|
it("round-trips through the injected storage", () => {
|
||||||
|
saveHistory([entry("persisted", "qq", 9)], storage);
|
||||||
|
expect(loadHistory(storage)).toEqual([entry("persisted", "qq", 9)]);
|
||||||
|
expect(Object.keys(storage.dump())).toEqual([SEARCH_HISTORY_STORAGE_KEY]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads [] when the key is absent", () => {
|
||||||
|
expect(loadHistory(storage)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads [] instead of throwing on corrupt storage", () => {
|
||||||
|
const bad = memoryStorage({ [SEARCH_HISTORY_STORAGE_KEY]: "}{" });
|
||||||
|
expect(loadHistory(bad)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearStoredHistory removes the key", () => {
|
||||||
|
saveHistory([entry("a")], storage);
|
||||||
|
clearStoredHistory(storage);
|
||||||
|
expect(storage.dump()).toEqual({});
|
||||||
|
expect(loadHistory(storage)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a storage that throws on every access", () => {
|
||||||
|
const hostile = {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("denied");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("denied");
|
||||||
|
},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error("denied");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(loadHistory(hostile)).toEqual([]);
|
||||||
|
expect(() => saveHistory([entry("a")], hostile)).not.toThrow();
|
||||||
|
expect(() => clearStoredHistory(hostile)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates a null storage (SSR / privacy mode)", () => {
|
||||||
|
expect(loadHistory(null)).toEqual([]);
|
||||||
|
expect(() => saveHistory([entry("a")], null)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sameEntry", () => {
|
||||||
|
it("compares platform and case-insensitive query", () => {
|
||||||
|
expect(sameEntry(entry("Hello", "qq"), " hello ", "qq")).toBe(true);
|
||||||
|
expect(sameEntry(entry("Hello", "qq"), "hello", "netease")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Search history for the search page.
|
||||||
|
*
|
||||||
|
* Deliberately client-side (localStorage, per browser): the bot is shared by
|
||||||
|
* many users, so a server-side history would leak one user's queries into
|
||||||
|
* another's suggestions. Everything here is a pure function except the three
|
||||||
|
* storage helpers, which accept an injected Storage-like so the list logic is
|
||||||
|
* unit-testable without a DOM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SEARCH_HISTORY_MAX = 10;
|
||||||
|
export const SEARCH_HISTORY_STORAGE_KEY = 'search-history';
|
||||||
|
|
||||||
|
export interface SearchHistoryEntry {
|
||||||
|
q: string;
|
||||||
|
platform: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||||
|
|
||||||
|
function defaultStorage(): StorageLike | null {
|
||||||
|
try {
|
||||||
|
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||||
|
} catch {
|
||||||
|
// Accessing localStorage throws in some privacy modes.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEntry(value: unknown): value is SearchHistoryEntry {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const e = value as Partial<SearchHistoryEntry>;
|
||||||
|
return (
|
||||||
|
typeof e.q === 'string' &&
|
||||||
|
e.q.trim().length > 0 &&
|
||||||
|
typeof e.platform === 'string' &&
|
||||||
|
e.platform.length > 0 &&
|
||||||
|
typeof e.at === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHistory(raw: string | null | undefined): SearchHistoryEntry[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter(isEntry).slice(0, SEARCH_HISTORY_MAX);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeHistory(list: SearchHistoryEntry[]): string {
|
||||||
|
return JSON.stringify(list.slice(0, SEARCH_HISTORY_MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameEntry(
|
||||||
|
entry: SearchHistoryEntry,
|
||||||
|
q: string,
|
||||||
|
platform: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
entry.platform === platform &&
|
||||||
|
entry.q.toLowerCase() === q.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest first. Repeating a query for the same source moves the existing entry
|
||||||
|
* to the front instead of adding a duplicate (case-insensitive on the query).
|
||||||
|
*/
|
||||||
|
export function pushHistory(
|
||||||
|
list: SearchHistoryEntry[],
|
||||||
|
q: string,
|
||||||
|
platform: string,
|
||||||
|
at = Date.now(),
|
||||||
|
): SearchHistoryEntry[] {
|
||||||
|
const query = q.trim();
|
||||||
|
if (!query || !platform) return list;
|
||||||
|
const rest = list.filter((e) => !sameEntry(e, query, platform));
|
||||||
|
return [{ q: query, platform, at }, ...rest].slice(0, SEARCH_HISTORY_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeHistory(
|
||||||
|
list: SearchHistoryEntry[],
|
||||||
|
q: string,
|
||||||
|
platform: string,
|
||||||
|
): SearchHistoryEntry[] {
|
||||||
|
return list.filter((e) => !sameEntry(e, q, platform));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entries for the currently selected source, newest first. */
|
||||||
|
export function historyForPlatform(
|
||||||
|
list: SearchHistoryEntry[],
|
||||||
|
platform: string,
|
||||||
|
): SearchHistoryEntry[] {
|
||||||
|
return list.filter((e) => e.platform === platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHistory(storage: StorageLike | null = defaultStorage()): SearchHistoryEntry[] {
|
||||||
|
try {
|
||||||
|
return parseHistory(storage?.getItem(SEARCH_HISTORY_STORAGE_KEY) ?? null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHistory(
|
||||||
|
list: SearchHistoryEntry[],
|
||||||
|
storage: StorageLike | null = defaultStorage(),
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(SEARCH_HISTORY_STORAGE_KEY, serializeHistory(list));
|
||||||
|
} catch {
|
||||||
|
// Storage full or unavailable — history is a convenience, not state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredHistory(storage: StorageLike | null = defaultStorage()): void {
|
||||||
|
try {
|
||||||
|
storage?.removeItem(SEARCH_HISTORY_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user