Files
teamspeak-music-bot/src/music/netease.ts
T
saopig1andClaude Opus 4.6 67584d2622 feat: add YesPlayMusic-style home page with daily recs, FM, and user playlists
Add backend endpoints for daily recommended songs, personal FM, user
playlists, and playlist detail. Extend NeteaseProvider and MusicProvider
interface with getDailyRecommendSongs and getUserPlaylists. Rewrite Home
page with FM card, daily recommendations grid, and user playlists section.
Fix Playlist view to fetch detail and songs from separate endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 02:07:11 +08:00

322 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
Playlist,
LyricLine,
SearchResult,
QrCodeResult,
AuthStatus,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
if (!lrc) return [];
const parseLine = (
line: string
): { time: number; text: string } | null => {
const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\](.+)$/);
if (!match) return null;
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
const ms = parseInt(match[3].padEnd(3, "0"), 10);
const text = match[4].trim();
if (/^(作词|作曲|编曲|制作|混音|母带)\s*[::]/.test(text)) return null;
return { time: minutes * 60 + seconds + ms / 1000, text };
};
const lines: LyricLine[] = [];
const translationMap = new Map<number, string>();
if (tlyric) {
for (const line of tlyric.split("\n")) {
const parsed = parseLine(line);
if (parsed) {
translationMap.set(Math.round(parsed.time * 100), parsed.text);
}
}
}
for (const line of lrc.split("\n")) {
const parsed = parseLine(line);
if (parsed) {
const timeKey = Math.round(parsed.time * 100);
lines.push({
time: parsed.time,
text: parsed.text,
translation: translationMap.get(timeKey),
});
}
}
return lines.sort((a, b) => a.time - b.time);
}
export class NeteaseProvider implements MusicProvider {
readonly platform = "netease" as const;
private api: AxiosInstance;
private cookie = "";
constructor(baseUrl: string) {
this.api = axios.create({
baseURL: baseUrl,
timeout: 10000,
});
}
private get cookieParams(): Record<string, string> {
return this.cookie ? { cookie: this.cookie } : {};
}
async search(query: string, limit = 20): Promise<SearchResult> {
const [songRes, playlistRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
this.api.get("/cloudsearch", {
params: {
keywords: query,
type: 1000,
limit: 5,
...this.cookieParams,
},
}),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
(s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
})
);
const playlists: Playlist[] = (
playlistRes.data?.result?.playlists ?? []
).map((p: any) => ({
id: String(p.id),
name: p.name,
coverUrl: p.coverImgUrl ?? "",
songCount: p.trackCount ?? 0,
platform: "netease",
}));
return { songs, playlists, albums: [] };
}
async getSongUrl(songId: string): Promise<string | null> {
const res = await this.api.get("/song/url/v1", {
params: { id: songId, level: "exhigh", ...this.cookieParams },
});
return res.data?.data?.[0]?.url ?? null;
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.api.get("/song/detail", {
params: { ids: songId, ...this.cookieParams },
});
const s = res.data?.songs?.[0];
if (!s) return null;
return {
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
};
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/playlist/track/all", {
params: { id: playlistId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
}
async getRecommendPlaylists(): Promise<Playlist[]> {
const res = await this.api.get("/personalized", {
params: { limit: 10, ...this.cookieParams },
});
return (res.data?.result ?? []).map((p: any) => ({
id: String(p.id),
name: p.name,
coverUrl: p.picUrl ?? "",
songCount: p.trackCount ?? 0,
platform: "netease",
}));
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
const res = await this.api.get("/album", {
params: { id: albumId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
}
async getLyrics(songId: string): Promise<LyricLine[]> {
const res = await this.api.get("/lyric", {
params: { id: songId, ...this.cookieParams },
});
return parseLyrics(
res.data?.lrc?.lyric ?? "",
res.data?.tlyric?.lyric
);
}
async getQrCode(): Promise<QrCodeResult> {
const keyRes = await this.api.get("/login/qr/key", {
params: { timestamp: Date.now() },
});
const key = keyRes.data?.data?.unikey ?? "";
const createRes = await this.api.get("/login/qr/create", {
params: { key, qrimg: true },
});
return {
qrUrl: createRes.data?.data?.qrurl ?? "",
qrImg: createRes.data?.data?.qrimg ?? "",
key,
};
}
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const res = await this.api.get("/login/qr/check", {
params: { key, timestamp: Date.now() },
});
const code = res.data?.code;
switch (code) {
case 801:
return "waiting";
case 802:
return "scanned";
case 803:
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return "confirmed";
default:
return "expired";
}
}
async sendSmsCode(phone: string): Promise<boolean> {
const res = await this.api.get("/captcha/sent", {
params: { phone },
});
return res.data?.code === 200;
}
async loginWithSms(phone: string, code: string): Promise<boolean> {
const res = await this.api.get("/captcha/verify", {
params: { phone, captcha: code },
});
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return res.data?.code === 200;
}
setCookie(cookie: string): void {
this.cookie = cookie;
}
getCookie(): string {
return this.cookie;
}
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
try {
const res = await this.api.get("/login/status", {
params: { ...this.cookieParams },
});
const profile = res.data?.data?.profile;
if (profile) {
return {
loggedIn: true,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
};
}
} catch {
// ignore
}
return { loggedIn: false };
}
async getPersonalFm(): Promise<Song[]> {
const res = await this.api.get("/personal_fm", {
params: { ...this.cookieParams },
});
return (res.data?.data ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.artists ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? "",
duration: Math.round((s.duration ?? 0) / 1000),
coverUrl: s.album?.picUrl ?? "",
platform: "netease",
}));
}
async getDailyRecommendSongs(): Promise<Song[]> {
const res = await this.api.get("/recommend/songs", {
params: { ...this.cookieParams },
});
return (res.data?.data?.dailySongs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
}
async getUserPlaylists(): Promise<Playlist[]> {
// First get user ID from login status
const statusRes = await this.api.get("/login/status", {
params: { ...this.cookieParams },
});
const uid = statusRes.data?.data?.profile?.userId;
if (!uid) return [];
const res = await this.api.get("/user/playlist", {
params: { uid, ...this.cookieParams },
});
return (res.data?.playlist ?? []).map((p: any) => ({
id: String(p.id),
name: p.name,
coverUrl: p.coverImgUrl ?? "",
songCount: p.trackCount ?? 0,
platform: "netease",
}));
}
}