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:
Slldyd2077 committed 2026-06-28 17:12:57 +08:00
1 parent 06aaec4ba5
commit 7b2bd0ea6a
5 files changed
+62 -59

No files matched your search

+22 -1
View File
@@ -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
View File
@@ -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> {
+3
View File
@@ -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 {
+14
View File
@@ -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 = [
{ {
+2
View File
@@ -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 [];