Files
teamspeak-music-bot/src/music/provider.ts
T
zzstar101 b6ad536bb7 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.
2026-10-02 01:36:03 +08:00

154 lines
4.3 KiB
TypeScript

/** All music source platforms. Jellyfin ItemIds are GUID strings — never
* assume numeric ids when handling a generic Platform. */
export type Platform =
| "netease"
| "qq"
| "bilibili"
| "youtube"
| "local"
| "kugou"
| "spotify"
| "jellyfin";
export interface Song {
id: string;
name: string;
artist: string;
album: string;
duration: number; // seconds
coverUrl: string;
platform: Platform;
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
}
export interface SongWithUrl extends Song {
url: string;
}
/** getSongUrl 解析结果。trialDuration 缺省 = 完整可播放(VIP 账号 / 免费曲)。 */
export interface SongUrlResult {
url: string;
/** 试听片段时长(秒)。VIP/免费曲为 undefined → 调用方回退完整 duration。 */
trialDuration?: number;
}
export interface Playlist {
id: string;
name: string;
coverUrl: string;
songCount: number;
platform: Platform;
}
export interface PlaylistDetail {
id: string;
name: string;
description: string;
coverUrl: string;
songCount: number;
}
export interface Album {
id: string;
name: string;
artist: string;
coverUrl: string;
songCount: number;
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;
translation?: string;
}
export interface SearchResult {
songs: Song[];
playlists: Playlist[];
albums: Album[];
/** Present only for sources with an artist entity (NetEase, QQ). */
artists?: Artist[];
}
export interface QrCodeResult {
qrUrl: string;
qrImg?: string; // base64 data URL of QR image
key: string;
}
export interface AuthStatus {
loggedIn: boolean;
nickname?: string;
avatarUrl?: string;
}
export interface MusicProvider {
readonly platform: Platform;
search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void;
getQuality(): string;
getSongDetail(songId: string): Promise<Song | null>;
getPlaylistSongs(playlistId: string): Promise<Song[]>;
getRecommendPlaylists(): Promise<Playlist[]>;
getAlbumSongs(albumId: string): Promise<Song[]>;
getLyrics(songId: string): Promise<LyricLine[]>;
getQrCode(): Promise<QrCodeResult>;
checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired">;
loginWithSms?(phone: string, code: string): Promise<boolean>;
sendSmsCode?(phone: string): Promise<boolean>;
setCookie(cookie: string): void;
getCookie(): string;
getAuthStatus(): Promise<AuthStatus>;
getPersonalFm?(): Promise<Song[]>;
getDailyRecommendSongs?(): Promise<Song[]>;
getUserPlaylists?(): Promise<Playlist[]>;
getPlaylistDetail?(playlistId: string): Promise<PlaylistDetail | null>;
getArtistDetail?(artistId: string): Promise<ArtistDetail | null>;
/** The artist's most popular tracks, best-first. */
getArtistSongs?(artistId: string, limit?: number): Promise<Song[]>;
/** One page of the artist's full catalogue, best-first. Sources that can only
* expose a fixed top-N list leave this unimplemented (the route then 501s and
* the web hides the "全部歌曲" section). */
getArtistAllSongs?(
artistId: string,
offset?: number,
limit?: number
): Promise<ArtistSongPage>;
getArtistAlbums?(artistId: string, limit?: number): Promise<Album[]>;
}