mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-06 06:52:49 +08:00
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.
127 lines
3.4 KiB
TypeScript
127 lines
3.4 KiB
TypeScript
/**
|
|
* 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.
|
|
}
|
|
}
|