mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: expose vip flag on Song for trial-only detection
Add optional vip?: boolean to the Song interface so downstream clients (e.g. PowerfulTS) can mark copyright-restricted songs that non-VIP users can only play as a trial fragment, before playback starts. - provider.ts: add optional vip?: boolean (backward compatible) - netease.ts: extract mapNeteaseSongs() pure fn; map fee to vip (1=VIP, 4=album-only). fee=8 (free low-quality) is excluded because it plays in full, just at lower quality. - qq.ts: mapQqSongs() maps pay.payplay/paytrackprice to vip (one fix covers all callers); getDailyRecommendSongs inline mapping too. - tests: vip mapping assertions for netease fee (1/4=vip, 0/8=free) and qq pay fields.
This commit is contained in:
1 parent
06aaec4ba5
commit
7b2bd0ea6a
5 files changed
+62
-59
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
|
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs } from "./netease.js";
|
||||||
|
|
||||||
describe("NetEase adapter", () => {
|
describe("NetEase adapter", () => {
|
||||||
it("parses LRC format lyrics", () => {
|
it("parses LRC format lyrics", () => {
|
||||||
@@ -56,4 +56,25 @@ describe("NetEase adapter", () => {
|
|||||||
expect(mapNeteaseAlbums(null as any)).toEqual([]);
|
expect(mapNeteaseAlbums(null as any)).toEqual([]);
|
||||||
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
|
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mapNeteaseSongs maps fee to vip flag (1/4 = vip, 0/8 = free)", () => {
|
||||||
|
const raw = [
|
||||||
|
{ id: 1, name: "VIP", ar: [{ name: "A" }], al: { name: "Al", picUrl: "p" }, dt: 180000, fee: 1 },
|
||||||
|
{ id: 2, name: "Album-only", ar: [], al: { name: "Al", picUrl: "" }, dt: 0, fee: 4 },
|
||||||
|
{ id: 3, name: "Free", ar: [], al: {}, dt: 0, fee: 0 },
|
||||||
|
{ id: 4, name: "Free low-quality", ar: [], al: {}, dt: 0, fee: 8 },
|
||||||
|
];
|
||||||
|
const out = mapNeteaseSongs(raw);
|
||||||
|
expect(out[0].vip).toBe(true);
|
||||||
|
expect(out[1].vip).toBe(true);
|
||||||
|
expect(out[2].vip).toBe(false);
|
||||||
|
expect(out[3].vip).toBe(false); // fee=8 plays in full (low quality), NOT vip
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mapNeteaseSongs accepts artists/album/duration aliases (personal_fm shape)", () => {
|
||||||
|
const out = mapNeteaseSongs([
|
||||||
|
{ id: 9, name: "FM", artists: [{ name: "B" }], album: { name: "Al2", picUrl: "p2" }, duration: 200000, fee: 0 },
|
||||||
|
]);
|
||||||
|
expect(out[0]).toMatchObject({ artist: "B", album: "Al2", coverUrl: "p2", vip: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+21
-58
@@ -68,6 +68,21 @@ export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((s: any) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name,
|
||||||
|
artist: (s.ar ?? s.artists ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.al?.name ?? s.album?.name ?? "",
|
||||||
|
duration: Math.round((s.dt ?? s.duration ?? 0) / 1000),
|
||||||
|
coverUrl: s.al?.picUrl ?? s.album?.picUrl ?? "",
|
||||||
|
platform: "netease",
|
||||||
|
// fee: 0=free, 1=VIP, 4=album-only, 8=free low-quality (plays in full, NOT vip)
|
||||||
|
vip: s.fee === 1 || s.fee === 4,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
|
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
|
||||||
export const NETEASE_QUALITY_LEVELS = [
|
export const NETEASE_QUALITY_LEVELS = [
|
||||||
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
|
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
|
||||||
@@ -121,17 +136,7 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
|
const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs);
|
||||||
(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[] = (
|
const playlists: Playlist[] = (
|
||||||
playlistRes.data?.result?.playlists ?? []
|
playlistRes.data?.result?.playlists ?? []
|
||||||
@@ -160,32 +165,14 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
const res = await this.api.get("/song/detail", {
|
const res = await this.api.get("/song/detail", {
|
||||||
params: { ids: songId, ...this.cookieParams },
|
params: { ids: songId, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
const s = res.data?.songs?.[0];
|
return mapNeteaseSongs(res.data?.songs)[0] ?? null;
|
||||||
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[]> {
|
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||||
const res = await this.api.get("/playlist/track/all", {
|
const res = await this.api.get("/playlist/track/all", {
|
||||||
params: { id: playlistId, ...this.cookieParams },
|
params: { id: playlistId, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
return (res.data?.songs ?? []).map((s: any) => ({
|
return mapNeteaseSongs(res.data?.songs);
|
||||||
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[]> {
|
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||||
@@ -205,15 +192,7 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
const res = await this.api.get("/album", {
|
const res = await this.api.get("/album", {
|
||||||
params: { id: albumId, ...this.cookieParams },
|
params: { id: albumId, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
return (res.data?.songs ?? []).map((s: any) => ({
|
return mapNeteaseSongs(res.data?.songs);
|
||||||
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[]> {
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
@@ -312,30 +291,14 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
const res = await this.api.get("/personal_fm", {
|
const res = await this.api.get("/personal_fm", {
|
||||||
params: { ...this.cookieParams },
|
params: { ...this.cookieParams },
|
||||||
});
|
});
|
||||||
return (res.data?.data ?? []).map((s: any) => ({
|
return mapNeteaseSongs(res.data?.data);
|
||||||
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[]> {
|
async getDailyRecommendSongs(): Promise<Song[]> {
|
||||||
const res = await this.api.get("/recommend/songs", {
|
const res = await this.api.get("/recommend/songs", {
|
||||||
params: { ...this.cookieParams },
|
params: { ...this.cookieParams },
|
||||||
});
|
});
|
||||||
return (res.data?.data?.dailySongs ?? []).map((s: any) => ({
|
return mapNeteaseSongs(res.data?.data?.dailySongs);
|
||||||
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 getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export interface Song {
|
|||||||
duration: number; // seconds
|
duration: number; // seconds
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||||
|
/** 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 {
|
export interface SongWithUrl extends Song {
|
||||||
|
|||||||
@@ -22,10 +22,24 @@ describe("QQ adapter", () => {
|
|||||||
duration: 243,
|
duration: 243,
|
||||||
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
|
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
|
||||||
platform: "qq",
|
platform: "qq",
|
||||||
|
vip: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mapQqSongs maps pay field to vip flag", () => {
|
||||||
|
const out = mapQqSongs([
|
||||||
|
{ mid: "v1", name: "VIP playplay", singer: [], album: {}, interval: 100, pay: { payplay: 1, paytrackprice: 0 } },
|
||||||
|
{ mid: "v2", name: "VIP trackprice", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 1 } },
|
||||||
|
{ mid: "f1", name: "Free", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 0 } },
|
||||||
|
{ mid: "f2", name: "No pay field", singer: [], album: {}, interval: 100 },
|
||||||
|
]);
|
||||||
|
expect(out[0].vip).toBe(true);
|
||||||
|
expect(out[1].vip).toBe(true);
|
||||||
|
expect(out[2].vip).toBe(false);
|
||||||
|
expect(out[3].vip).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
||||||
const raw = [
|
const raw = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
|||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
||||||
: "",
|
: "",
|
||||||
platform: "qq" as const,
|
platform: "qq" as const,
|
||||||
|
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
|
||||||
};
|
};
|
||||||
}).filter((s) => s.id);
|
}).filter((s) => s.id);
|
||||||
}
|
}
|
||||||
@@ -520,6 +521,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||||
: "",
|
: "",
|
||||||
platform: "qq",
|
platform: "qq",
|
||||||
|
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
Reference in new issue
Block a user