diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts index 800689f..4e8e961 100644 --- a/src/music/netease.test.ts +++ b/src/music/netease.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi } from "vitest"; -import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js"; +import { + parseLyrics, + mapNeteaseAlbums, + mapNeteaseSongs, + mapNeteaseArtists, + parseNeteaseTrial, + NeteaseProvider, +} from "./netease.js"; describe("NetEase adapter", () => { it("parses LRC format lyrics", () => { @@ -138,6 +145,75 @@ describe("NeteaseProvider.search pagination", () => { expect(callByType(get, 1000).offset).toBe(0); expect(callByType(get, 10).offset).toBe(0); }); + + it("requests artists (type=100) and returns them alongside songs/albums/playlists", async () => { + const p = new NeteaseProvider("http://x"); + const get = vi.fn(async (_path: string, cfg: any) => ({ + data: + cfg.params.type === 100 + ? { result: { artists: [{ id: 6452, name: "Adele", picUrl: "http://p/1.jpg", musicSize: 120 }] } } + : { result: { songs: [], playlists: [], albums: [] } }, + })); + (p as any).api = { get }; + + const res = await p.search("adele", 20, 0); + + expect(callByType(get, 100).limit).toBe(20); + expect(callByType(get, 100).offset).toBe(0); + expect(res.artists).toEqual([ + { + id: "6452", + name: "Adele", + avatarUrl: "http://p/1.jpg", + aliases: [], + songCount: 120, + albumCount: undefined, + platform: "netease", + }, + ]); + }); +}); + +describe("mapNeteaseArtists (artist search + detail)", () => { + it("maps cloudsearch type=100 artist entries", () => { + const out = mapNeteaseArtists([ + { + id: 6452, + name: "Adele", + picUrl: "http://p/1.jpg", + alias: ["阿黛尔"], + musicSize: 120, + albumSize: 9, + }, + ]); + expect(out).toEqual([ + { + id: "6452", + name: "Adele", + avatarUrl: "http://p/1.jpg", + aliases: ["阿黛尔"], + songCount: 120, + albumCount: 9, + platform: "netease", + }, + ]); + }); + + it("falls back to img1v1Url/alia and drops non-string or empty aliases", () => { + const out = mapNeteaseArtists([ + { id: 1, name: "X", img1v1Url: "http://p/2.jpg", alia: ["a", "", null, 3] }, + ]); + expect(out[0].avatarUrl).toBe("http://p/2.jpg"); + expect(out[0].aliases).toEqual(["a"]); + expect(out[0].songCount).toBeUndefined(); + expect(out[0].albumCount).toBeUndefined(); + }); + + it("returns [] for empty/null input", () => { + expect(mapNeteaseArtists([])).toEqual([]); + expect(mapNeteaseArtists(null as any)).toEqual([]); + expect(mapNeteaseArtists(undefined as any)).toEqual([]); + }); }); describe("NeteaseProvider per-user login (#164)", () => { @@ -184,3 +260,57 @@ describe("NeteaseProvider per-user login (#164)", () => { expect(personal.platform).toBe("netease"); }); }); + +describe("NeteaseProvider.getArtistAllSongs (全部歌曲 paging)", () => { + const rawSongs = [ + { id: 1, name: "A", artists: [{ name: "X" }], album: { name: "Al" }, duration: 200000, fee: 0 }, + { id: 2, name: "B", artists: [{ name: "X" }], album: { name: "Al" }, duration: 100000, fee: 0 }, + ]; + + function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) { + const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) })); + (p as any).api = { get }; + return get; + } + + it("pages /artist/songs with order=hot and reports total/hasMore", async () => { + const p = new NeteaseProvider("http://x"); + const get = withGet(p, () => ({ songs: rawSongs, total: 345, more: true })); + + const page = await p.getArtistAllSongs("46487", 50, 50); + + expect(get).toHaveBeenCalledTimes(1); + expect(get.mock.calls[0][0]).toBe("/artist/songs"); + expect(get.mock.calls[0][1].params).toMatchObject({ + id: "46487", + limit: 50, + offset: 50, + order: "hot", + }); + expect(page.songs.map((s) => s.id)).toEqual(["1", "2"]); + expect(page.total).toBe(345); + expect(page.hasMore).toBe(true); + }); + + it("derives hasMore from total when the upstream omits `more`", async () => { + const p = new NeteaseProvider("http://x"); + withGet(p, (_path, cfg) => ({ + songs: rawSongs.slice(cfg.params.offset, cfg.params.offset + cfg.params.limit), + total: 2, + })); + + expect((await p.getArtistAllSongs("1", 0, 1)).hasMore).toBe(true); + expect((await p.getArtistAllSongs("1", 1, 1)).hasMore).toBe(false); + }); + + it("clamps limit to 100, offset to >= 0, and derives a total when absent", async () => { + const p = new NeteaseProvider("http://x"); + const get = withGet(p, () => ({ songs: rawSongs })); + + const page = await p.getArtistAllSongs("1", -5, 500); + + expect(get.mock.calls[0][1].params).toMatchObject({ limit: 100, offset: 0 }); + expect(page.total).toBe(2); + expect(page.hasMore).toBe(false); + }); +}); diff --git a/src/music/netease.ts b/src/music/netease.ts index 05bcd14..528859a 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -10,6 +10,9 @@ import type { QrCodeResult, AuthStatus, Album, + Artist, + ArtistDetail, + ArtistSongPage, } from "./provider.js"; export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] { @@ -69,6 +72,21 @@ export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] { })); } +export function mapNeteaseArtists(raw: any[] | null | undefined): Artist[] { + if (!Array.isArray(raw)) return []; + return raw.map((a: any) => ({ + id: String(a.id), + name: a.name ?? "", + avatarUrl: a.picUrl ?? a.img1v1Url ?? "", + aliases: (a.alias ?? a.alia ?? []).filter( + (x: unknown): x is string => typeof x === "string" && x.length > 0 + ), + songCount: a.musicSize ?? undefined, + albumCount: a.albumSize ?? undefined, + platform: "netease", + })); +} + export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] { if (!Array.isArray(raw)) return []; return raw.map((s: any) => ({ @@ -137,7 +155,7 @@ export class NeteaseProvider implements MusicProvider { // /cloudsearch supports offset for every type. Songs, playlists (type 1000) // and albums (type 10) are all limit/offset-driven so the web can page past // the first page (playlists/albums were previously hardcoded to limit: 10). - const [songRes, playlistRes, albumRes] = await Promise.all([ + const [songRes, playlistRes, albumRes, artistRes] = await Promise.all([ this.api.get("/cloudsearch", { params: { keywords: query, type: 1, limit, offset, ...this.cookieParams }, }), @@ -153,6 +171,9 @@ export class NeteaseProvider implements MusicProvider { this.api.get("/cloudsearch", { params: { keywords: query, type: 10, limit, offset, ...this.cookieParams }, }), + this.api.get("/cloudsearch", { + params: { keywords: query, type: 100, limit, offset, ...this.cookieParams }, + }), ]); const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs); @@ -169,7 +190,9 @@ export class NeteaseProvider implements MusicProvider { const albums = mapNeteaseAlbums(albumRes.data?.result?.albums); - return { songs, playlists, albums }; + const artists = mapNeteaseArtists(artistRes.data?.result?.artists); + + return { songs, playlists, albums, artists }; } async getSongUrl(songId: string, quality?: string): Promise { @@ -217,6 +240,69 @@ export class NeteaseProvider implements MusicProvider { return mapNeteaseSongs(res.data?.songs); } + async getArtistDetail(artistId: string): Promise { + // /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 { + 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 { + 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 { + 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 { const res = await this.api.get("/lyric", { params: { id: songId, ...this.cookieParams }, diff --git a/src/music/provider.ts b/src/music/provider.ts index d63630b..ba9990c 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -59,6 +59,34 @@ export interface Album { platform: Platform; } +/** An artist / singer entity. Only sources with a real artist concept expose + * these (NetEase, QQ); the others simply never return `SearchResult.artists` + * and leave the optional provider methods unimplemented. */ +export interface Artist { + id: string; + name: string; + avatarUrl: string; + platform: Platform; + /** Alternate names / romanizations (NetEase alias, QQ other_name). */ + aliases?: string[]; + songCount?: number; + albumCount?: number; +} + +export interface ArtistDetail extends Artist { + /** Short biography, when the source provides one. */ + description?: string; +} + +/** One page of an artist's COMPLETE catalogue (the "全部歌曲" list), as opposed + * to `getArtistSongs`, which only ever returns the hot top-N. */ +export interface ArtistSongPage { + songs: Song[]; + /** Total tracks the source reports for this artist (best effort). */ + total: number; + hasMore: boolean; +} + export interface LyricLine { time: number; // seconds text: string; @@ -69,6 +97,8 @@ export interface SearchResult { songs: Song[]; playlists: Playlist[]; albums: Album[]; + /** Present only for sources with an artist entity (NetEase, QQ). */ + artists?: Artist[]; } export interface QrCodeResult { @@ -108,4 +138,16 @@ export interface MusicProvider { getDailyRecommendSongs?(): Promise; getUserPlaylists?(): Promise; getPlaylistDetail?(playlistId: string): Promise; + getArtistDetail?(artistId: string): Promise; + /** The artist's most popular tracks, best-first. */ + getArtistSongs?(artistId: string, limit?: number): Promise; + /** 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; + getArtistAlbums?(artistId: string, limit?: number): Promise; } diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts index eafae0c..53f5d03 100644 --- a/src/music/qq.test.ts +++ b/src/music/qq.test.ts @@ -8,7 +8,7 @@ vi.mock("axios", () => ({ default: { create: () => ({ get: mockGet, post: mockPost }) }, })); -import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js"; +import { mapQqAlbums, mapQqArtists, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js"; describe("QQ adapter", () => { it("mapQqSongs maps QQMusicApi-style song entries", () => { @@ -171,4 +171,306 @@ describe("QQMusicProvider.search pagination", () => { expect(songCall, "expected a client_search_cp song call").toBeTruthy(); expect(songCall![1].params.p).toBe(2); }); + + it("adds the singer sub-request (search_type 1) to the same musicu batch", async () => { + musicuOk(); + const p = new QQMusicProvider("http://x"); + await p.search("周杰伦", 20, 0); + + const d = musicuReqData(); + expect(d.req_artist.param.search_type).toBe(1); + expect(d.req_artist.param.num_per_page).toBe(20); + expect(d.req_artist.param.page_num).toBe(1); + }); + + it("returns singers even when the song list is empty (no client_search_cp fallback)", async () => { + mockGet.mockImplementation(async (url: string) => { + if (url === "/cgi-bin/musicu.fcg") { + return { + data: { + req_0: { data: { body: { song: { list: [] } } } }, + req_album: { data: { body: { album: { list: [] } } } }, + req_playlist: { data: { body: { songlist: { list: [] } } } }, + req_artist: { + data: { body: { singer: { list: [{ singerMID: "m1", singerName: "Adele", songNum: 88 }] } } }, + }, + }, + }; + } + return { data: {} }; + }); + const p = new QQMusicProvider("http://x"); + const res = await p.search("Adele", 20, 0); + + expect(res.songs).toEqual([]); + expect(res.artists).toEqual([ + { + id: "m1", + name: "Adele", + avatarUrl: "https://y.gtimg.cn/music/photo_new/T001R500x500M000m1.jpg", + songCount: 88, + albumCount: undefined, + platform: "qq", + }, + ]); + expect(mockGet.mock.calls.some((c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp")).toBe(false); + }); +}); + +describe("mapQqArtists (singer search + detail)", () => { + it("maps singer list entries and builds the 500px portrait from the MID", () => { + const out = mapQqArtists([ + { + singerMID: "abc", + singerName: "周杰伦", + singerPic: "http://y.gtimg.cn/music/photo_new/T001R150x150M000abc_11.jpg", + songNum: 500, + albumNum: 30, + }, + ]); + expect(out).toEqual([ + { + id: "abc", + name: "周杰伦", + avatarUrl: "https://y.gtimg.cn/music/photo_new/T001R500x500M000abc.jpg", + songCount: 500, + albumCount: 30, + platform: "qq", + }, + ]); + }); + + it("falls back to the given picture when no MID is present", () => { + const out = mapQqArtists([ + { singerID: 42, singerName: "Y", singerPic: "https://y.gtimg.cn/music/photo_new/x.jpg" }, + ]); + expect(out).toEqual([ + { + id: "42", + name: "Y", + avatarUrl: "https://y.gtimg.cn/music/photo_new/x.jpg", + songCount: undefined, + albumCount: undefined, + platform: "qq", + }, + ]); + }); + + it("drops entries without an id or name and tolerates empty input", () => { + expect(mapQqArtists([{ singerName: "no id" }, { singerMID: "x" }])).toEqual([]); + expect(mapQqArtists([])).toEqual([]); + expect(mapQqArtists(null as any)).toEqual([]); + expect(mapQqArtists(undefined as any)).toEqual([]); + }); +}); + +describe("QQMusicProvider.getArtistAllSongs (album aggregation)", () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + function songRaw(mid: string, title: string) { + return { mid, title, singer: [{ name: "Adele" }], album: { mid: "al1", name: "Album" }, interval: 200 }; + } + + /** singer detail (top 50) + album search pages + per-album song lists. */ + function mockCatalogue(opts: { + hot?: any[]; + albumSearch?: (page: number) => any[]; + albumSongs?: Record; + 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); + }); }); diff --git a/src/music/qq.ts b/src/music/qq.ts index ec77d45..674fbc3 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -10,6 +10,9 @@ import type { QrCodeResult, AuthStatus, Album, + Artist, + ArtistDetail, + ArtistSongPage, } from "./provider.js"; import { parseLyrics } from "./netease.js"; @@ -40,6 +43,28 @@ const qqFavApi = axios.create({ headers: { referer: "https://y.qq.com/" }, }); +/** True when a search_type=2 album search entry really belongs to this singer. + * QQ fills singerMID for most albums; older entries only carry singer_list. */ +function isArtistAlbum(a: any, artistId: string): boolean { + const mid = a?.singerMID ?? a?.singer_mid; + if (mid) return String(mid) === artistId; + const singers = a?.singer_list ?? a?.singer ?? []; + return ( + Array.isArray(singers) && + singers.some((s: any) => String(s?.mid ?? s?.singerMID ?? "") === artistId) + ); +} + +/** Assembling a QQ singer's full catalogue costs one album-song request per + * album, so the merged list is memoised per singer for a while. */ +const ARTIST_CATALOG_TTL_MS = 10 * 60 * 1000; +const ARTIST_CATALOG_MAX_ENTRIES = 20; +/** Album search pages to walk (50 albums each) and the hard cap on albums we + * are willing to fetch songs for. */ +const ARTIST_ALBUM_MAX_PAGES = 3; +const ARTIST_ALBUM_MAX = 50; +const ARTIST_ALBUM_CONCURRENCY = 5; + export function mapQqSongs(raw: any[] | null | undefined): Song[] { if (!Array.isArray(raw)) return []; return raw.map((s) => { @@ -91,6 +116,33 @@ export function mapQqAlbums(raw: any[] | null | undefined): Album[] { }); } +/** QQ hands out http:// image URLs; the WebUI is often served over https. */ +function httpsImage(url: unknown): string { + return typeof url === "string" ? url.replace(/^http:\/\//i, "https://") : ""; +} + +export function mapQqArtists(raw: any[] | null | undefined): Artist[] { + if (!Array.isArray(raw)) return []; + return raw + .map((a) => { + const id = String(a.singerMID ?? a.singer_mid ?? a.mid ?? a.singerID ?? a.singerId ?? ""); + const mid = a.singerMID ?? a.singer_mid ?? a.mid; + return { + id, + name: a.singerName ?? a.name ?? "", + // Search returns a 150px portrait; the MID builds the 500px one the + // artist page wants, so prefer it and only fall back to the given URL. + avatarUrl: mid + ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg` + : httpsImage(a.singerPic ?? a.pic), + songCount: a.songNum ?? undefined, + albumCount: a.albumNum ?? undefined, + platform: "qq" as const, + }; + }) + .filter((a) => a.id && a.name); +} + function computeGtk(pSkey: string): number { let hash = 5381; for (let i = 0; i < pSkey.length; i++) { @@ -195,6 +247,13 @@ export class QQMusicProvider implements MusicProvider { method: "DoSearchForQQMusicDesktop", param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 }, }, + // search_type 1 = singers. Folded into the same batch so artist search + // costs no extra round-trip. + req_artist: { + module: "music.search.SearchCgiService", + method: "DoSearchForQQMusicDesktop", + param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 1 }, + }, }); const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", { params: { format: "json", data: reqData }, @@ -202,7 +261,11 @@ export class QQMusicProvider implements MusicProvider { const songList: any[] = res.data?.req_0?.data?.body?.song?.list ?? []; - if (songList.length === 0) return null; + const artistList: any[] = + res.data?.req_artist?.data?.body?.singer?.list ?? []; + // Only fall back to the older client_search_cp path when the batch came + // back completely empty — an artist-only hit is a real result. + if (songList.length === 0 && artistList.length === 0) return null; const songs = mapQqSongs(songList); @@ -218,7 +281,7 @@ export class QQMusicProvider implements MusicProvider { platform: "qq" as const, })); - return { songs, playlists, albums }; + return { songs, playlists, albums, artists: mapQqArtists(artistList) }; } catch { return null; } @@ -470,6 +533,196 @@ export class QQMusicProvider implements MusicProvider { return mapQqSongs(res.data?.response?.data?.list ?? []); } + /** music.web_singer_info_svr / get_singer_detail_info — singer info plus up + * to `num` of their hottest songs (sort 5 = popularity). Returns null on any + * failure so callers can degrade instead of throwing. */ + private async fetchSingerDetail(singerMid: string, num: number): Promise { + 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 { + 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 { + 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 { + 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(); + + private async buildArtistCatalog(artistId: string): Promise { + 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(); + 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 { + const detail = await this.fetchSingerDetail(artistId, 1); + const name = detail?.singer_info?.name; + if (!name) return []; + const ids: string[] = []; + const seen = new Set(); + 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 { + 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 { + // 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 { const res = await this.api.get("/getLyric", { params: { songmid: songId, ...this.cookieParams }, diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index b066b8b..73b8dbd 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -503,3 +503,117 @@ describe("music router GET /bilibili/parts", () => { expect(res.status).toBe(404); }); }); + +describe("music router GET /artist/:id", () => { + function artistProvider(overrides: Record = {}): 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" }, + ]); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index f54876a..470f4f0 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -1,5 +1,5 @@ import express, { Router, type Response } from "express"; -import type { MusicProvider, Song, Album } from "../../music/provider.js"; +import type { MusicProvider, Song, Album, SearchResult } from "../../music/provider.js"; import { YouTubeProvider } from "../../music/youtube.js"; import type { Logger } from "../../logger.js"; import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js"; @@ -198,7 +198,7 @@ export function createMusicRouter( // searched. Jellyfin (an opt-in source) leads the merged results when // enabled — a self-hosted library match is almost always the wanted one. const enabled = (p: string) => !config || isProviderEnabled(config, p); - const none = { songs: [], albums: [], playlists: [] }; + const none: SearchResult = { songs: [], albums: [], playlists: [] }; const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([ jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none), enabled("netease") ? neteaseProvider.search(q as string, parsedLimit) : Promise.resolve(none), @@ -226,8 +226,14 @@ export function createMusicRouter( ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []), ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []), ]; + // Artists come only from the sources that model them (netease/qq); other + // providers simply contribute nothing. + const artists = [ + ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.artists ?? [] : []), + ...(qqResult.status === "fulfilled" ? qqResult.value.artists ?? [] : []), + ]; - res.json({ songs, albums, playlists }); + res.json({ songs, albums, playlists, artists }); } catch (err) { logger.error({ err }, "Unified search failed"); res.status(500).json({ error: (err as Error).message }); @@ -297,6 +303,36 @@ export function createMusicRouter( } }); + router.get("/artist/:id", async (req, res) => { + try { + const provider = resolveProvider(req.query.platform, res); + if (!provider) return; + if (!provider.getArtistDetail) { + res.status(501).json({ error: "Not supported by this provider" }); + return; + } + // Each piece degrades independently: a source that cannot list albums (or + // a transient upstream failure) must not take the hero or the songs down + // with it, so every call falls back to an empty value. + const [artist, songs, albums] = await Promise.all([ + provider.getArtistDetail(req.params.id).catch(() => null), + provider.getArtistSongs + ? provider.getArtistSongs(req.params.id).catch(() => [] as Song[]) + : Promise.resolve([] as Song[]), + provider.getArtistAlbums + ? provider.getArtistAlbums(req.params.id).catch(() => [] as Album[]) + : Promise.resolve([] as Album[]), + ]); + if (!artist) { + res.status(404).json({ error: "Artist not found" }); + return; + } + res.json({ artist, songs, albums }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + router.get("/recommend/songs", requireNotGuest, async (req, res) => { try { const provider = resolveProvider(req.query.platform, res); diff --git a/src/web/api/permissions-enforcement.test.ts b/src/web/api/permissions-enforcement.test.ts index 9b90aa4..a4d0455 100644 --- a/src/web/api/permissions-enforcement.test.ts +++ b/src/web/api/permissions-enforcement.test.ts @@ -374,13 +374,15 @@ describe("guest enforcement on player routes", () => { expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403); }); - it("playCollection flag gates /play-playlist, /play-album (issue #103)", async () => { + it("playCollection flag gates /play-playlist, /play-album, /play-artist (issue #103)", async () => { const allow = mountGuest({ playCollection: true }); const deny = mountGuest({ playCollection: false }); expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).not.toBe(403); expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).not.toBe(403); + expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-artist`).send({ artistId: "1" })).status).not.toBe(403); expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403); expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403); + expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-artist`).send({ artistId: "1" })).status).toBe(403); // playCollection does NOT leak into the destructive single-song / queue ops. expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403); expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403); diff --git a/src/web/api/play-artist.test.ts b/src/web/api/play-artist.test.ts new file mode 100644 index 0000000..e46bc62 --- /dev/null +++ b/src/web/api/play-artist.test.ts @@ -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 = { + 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); + }); +}); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index ab67326..0e2fdaa 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -1,13 +1,38 @@ import { Router } from "express"; import type { BotManager } from "../../bot/manager.js"; import type { BotDatabase } from "../../data/database.js"; -import type { MusicProvider } from "../../music/provider.js"; +import type { MusicProvider, Song, ArtistSongPage } from "../../music/provider.js"; import type { Logger } from "../../logger.js"; import { parseCommand } from "../../bot/commands.js"; import { requireBotAccess } from "../middleware/requirePermission.js"; import { authorize } from "../middleware/authorize.js"; import { supportsPersonalLogin } from "./personal-music.js"; +/** Hard cap on how many tracks one "播放全部" request may queue — a safety net + * against a pathological catalogue (and against an upstream paging bug). */ +const MAX_ARTIST_QUEUE = 500; +const ARTIST_QUEUE_PAGE = 100; + +/** Walks every page of an artist's catalogue (best-first, de-duplicated). */ +export async function collectArtistSongs( + fetchPage: (artistId: string, offset?: number, limit?: number) => Promise, + artistId: string +): Promise { + const songs: Song[] = []; + const seen = new Set(); + for (let offset = 0; offset < MAX_ARTIST_QUEUE; offset += ARTIST_QUEUE_PAGE) { + const page = await fetchPage(artistId, offset, ARTIST_QUEUE_PAGE); + for (const song of page.songs) { + if (!seen.has(song.id)) { + seen.add(song.id); + songs.push(song); + } + } + if (!page.hasMore || page.songs.length === 0) break; + } + return songs; +} + export function createPlayerRouter( botManager: BotManager, logger: Logger, @@ -470,6 +495,100 @@ export function createPlayerRouter( } }); + // Play an artist's songs. An artist page queues the singer's FULL catalogue — + // never just the hot 50 — so this pages through getArtistAllSongs when the + // source can page a catalogue, and falls back to getArtistSongs (hot songs) + // when it cannot. + router.post("/:botId/play-artist", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => { + try { + const bot = (req as any).bot; + const { artistId, platform } = req.body; + if (!artistId) { + res.status(400).json({ error: "artistId is required" }); + return; + } + if (isLocalAudioDisabled(bot, platform)) { + rejectDisabledLocalAudio(res); + return; + } + const provider = bot.getProviderFor( + platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin" + ? platform + : "netease" + ); + if (typeof provider.getArtistSongs !== "function") { + res.status(501).json({ error: "Not supported by this provider" }); + return; + } + // Whole catalogue when the source can page it (bounded by the collector's + // safety cap); otherwise the hot songs are the best it can offer. + const fetchPage = provider.getArtistAllSongs?.bind(provider); + + // Stop current playback + bot.getPlayer().stop(); + bot.getPlayer().resetFailures(); + + const songs = fetchPage + ? await collectArtistSongs(fetchPage, artistId) + : await provider.getArtistSongs(artistId, 50); + if (songs.length === 0) { + res.json({ ok: false, message: "该歌手暂无可用歌曲" }); + return; + } + + // Same QQ batch-resolve optimization as play-album: drop tracks that are + // region/copyright blocked instead of burning retries on them. + let queueable: { id: string }[] = songs; + const totalCount = songs.length; + const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise | null> }; + if (typeof qqLike.getPlayableSongIds === "function") { + const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id)); + if (playable !== null) { + queueable = songs.filter((s: { id: string }) => playable.has(s.id)); + } + } + if (queueable.length === 0) { + res.json({ ok: false, message: `歌手 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` }); + return; + } + + const queue = bot.getQueueManager(); + queue.clear(); + for (const song of queueable) { + queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) }); + } + // Sweep AFTER the queue is rebuilt (see play-playlist). + bot.cleanupQueuedLocalSongs?.("queue_replaced"); + + const mode = queue.getMode(); + let first; + if (mode === "random" || mode === "rloop") { + const idx = Math.floor(Math.random() * queue.size()); + first = queue.playAt(idx); + } else { + first = queue.play(); + } + + let started = first ? await bot.resolveAndPlay(first) : false; + if (first && !started) { + started = await bot.playNext(20); + } + + const playing = queue.current(); + const loadedMsg = queueable.length < totalCount + ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)` + : `已加载 ${queueable.length} 首`; + if (started && playing) { + res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` }); + } else { + res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` }); + } + } catch (err) { + logger.error({ err }, "play-artist failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + // Play a single song by ID — resolves URL on demand. Funnels through // bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs // insert-and-jump, keeping the queue) lives in one place shared with chat diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 03d3b31..1cf43df 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -20,6 +20,7 @@ const router = createRouter({ component: () => import('../views/Playlist.vue'), meta: { kind: 'album' }, }, + { path: '/artist/:id', name: 'artist', component: () => import('../views/Artist.vue') }, { path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') }, { path: '/history', name: 'history', component: () => import('../views/History.vue') }, { path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') }, diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 696033a..5cf6be6 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -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() { if (!this.activeBotId) return; // Freeze elapsed at the current LIVE interpolated value. Using the cached diff --git a/web/src/views/Artist.layout.test.ts b/web/src/views/Artist.layout.test.ts new file mode 100644 index 0000000..5a7583d --- /dev/null +++ b/web/src/views/Artist.layout.test.ts @@ -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("全部歌曲"); + 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"); + }); +}); diff --git a/web/src/views/Artist.vue b/web/src/views/Artist.vue new file mode 100644 index 0000000..29519e9 --- /dev/null +++ b/web/src/views/Artist.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index 60fb1f3..0066a82 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -12,9 +12,35 @@ v-model="query" class="search-input" placeholder="搜索歌曲、歌手、专辑..." + @focus="historyOpen = true" + @blur="historyOpen = false" @keyup.enter="doSearch" autofocus /> + + +
+
+ 搜索历史 + +
+
+ + {{ h.q }} + {{ badgeLabel(h.platform) }} + +
+
搜索中...
- @@ -200,6 +269,14 @@ import type { Song } from '../stores/player.js'; import SongCard from '../components/SongCard.vue'; import CoverArt from '../components/CoverArt.vue'; import { mergeDedup, hasMore, nextOffset } from './searchPagination.js'; +import { + clearStoredHistory, + loadHistory, + pushHistory, + removeHistory, + saveHistory, + type SearchHistoryEntry, +} from './searchHistory.js'; const PAGE_SIZE = 20; @@ -229,11 +306,17 @@ const selectedSource = ref(loadSource()); 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 Artist { id: string; name: string; avatarUrl: string; platform: string; songCount?: number; albumCount?: number; } const allSongs = ref([]); const allAlbums = ref([]); const allPlaylists = ref([]); // "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。 +const allArtists = ref([]); +// Search history lives in localStorage (see searchHistory.ts) — loaded on mount +// so SSR/blocked-storage environments simply start empty. +const historyEntries = ref([]); +const historyOpen = ref(false); const hasMoreMap = ref>({}); const loadingMore = ref>({ songs: false, albums: false, playlists: false }); const loading = ref(false); @@ -257,6 +340,10 @@ const filteredPlaylists = computed(() => 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')); // 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() { if (!query.value.trim()) return; + recordHistory(); loading.value = true; searched.value = true; activeTab.value = 'songs'; @@ -393,16 +481,43 @@ async function doSearch() { allSongs.value = res.data.songs ?? []; allAlbums.value = res.data.albums ?? []; allPlaylists.value = res.data.playlists ?? []; + allArtists.value = res.data.artists ?? []; recordInitialHasMore(allSongs.value, 'songs'); recordInitialHasMore(allAlbums.value, 'albums'); recordInitialHasMore(allPlaylists.value, 'playlists'); } catch { - allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; + allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allArtists.value = []; } finally { 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. */ const UPLOAD_MAX_MB = 500; @@ -546,6 +661,7 @@ async function loadLocalAudioSetting() { onMounted(async () => { loadLocalAudioSetting(); + historyEntries.value = loadHistory(); if (query.value) doSearch(); await store.fetchProviders(); fixupSelectedSource(); @@ -649,6 +765,7 @@ onMounted(async () => { } .search-input-wrap { + position: relative; display: flex; align-items: center; padding: 14px 20px; @@ -657,6 +774,188 @@ onMounted(async () => { 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 { font-size: 22px; opacity: 0.4; diff --git a/web/src/views/searchHistory.test.ts b/web/src/views/searchHistory.test.ts new file mode 100644 index 0000000..1f0927e --- /dev/null +++ b/web/src/views/searchHistory.test.ts @@ -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 = {}) { + 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; + + 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); + }); + }); +}); diff --git a/web/src/views/searchHistory.ts b/web/src/views/searchHistory.ts new file mode 100644 index 0000000..6a23eab --- /dev/null +++ b/web/src/views/searchHistory.ts @@ -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; + +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; + 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. + } +}