diff --git a/src/bot/instance.ts b/src/bot/instance.ts index af3867f..05348ab 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -52,6 +52,8 @@ export interface BotStatus { volume: number; playMode: PlayMode; elapsed: number; // ground truth elapsed seconds from frame count + /** 当前曲实际播放时长(秒)。试听片段=试听秒数;完整曲=duration。缺失时前端回退 currentSong.duration。 */ + effectiveDuration?: number; } export class BotInstance extends EventEmitter { @@ -81,6 +83,8 @@ export class BotInstance extends EventEmitter { private fmProvider: MusicProvider | null = null; /** Results of the most recent !search, for "#N" selection (issue #90). */ private lastSearchResults: Song[] = []; + /** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */ + private effectiveDuration: number | undefined; private playGate: Promise = Promise.resolve(); constructor(options: BotInstanceOptions) { @@ -508,8 +512,8 @@ export class BotInstance extends EventEmitter { this.voteSkipUsers.clear(); const provider = this.getProviderFor(song.platform); try { - const url = await provider.getSongUrl(song.id); - if (!url) { + const result = await provider.getSongUrl(song.id); + if (!result?.url) { this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping"); return false; } @@ -525,8 +529,10 @@ export class BotInstance extends EventEmitter { ); return false; } - song.url = url; - this.player.play(url, 0, song.duration); + song.url = result.url; + // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration + this.effectiveDuration = result.trialDuration ?? song.duration; + this.player.play(result.url, 0, this.effectiveDuration); // Fresh playback (re)start — clear auto-pause so a later occupancy // change won't try to "resume" a track the user already restarted. this.autoPaused = false; @@ -1119,6 +1125,7 @@ export class BotInstance extends EventEmitter { volume: this.player.getVolume(), playMode: this.queue.getMode(), elapsed: this.player.getElapsed(), + effectiveDuration: this.effectiveDuration, }; } diff --git a/src/music/bilibili.ts b/src/music/bilibili.ts index 7a692d2..1942f02 100644 --- a/src/music/bilibili.ts +++ b/src/music/bilibili.ts @@ -3,6 +3,7 @@ import axios, { type AxiosInstance } from "axios"; import type { MusicProvider, Song, + SongUrlResult, Playlist, LyricLine, SearchResult, @@ -231,7 +232,7 @@ export class BiliBiliProvider implements MusicProvider { return this.cidCache.get(bvid) ?? null; } - async getSongUrl(songId: string, _quality?: string): Promise { + async getSongUrl(songId: string, _quality?: string): Promise { const cid = await this.getCid(songId); if (!cid) return null; @@ -253,7 +254,8 @@ export class BiliBiliProvider implements MusicProvider { (b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a ); - return best.baseUrl ?? best.base_url ?? null; + const biliUrl = best.baseUrl ?? best.base_url; + return biliUrl ? { url: biliUrl } : null; } catch { return null; } diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts index ecc2202..af68d94 100644 --- a/src/music/netease.test.ts +++ b/src/music/netease.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseLyrics, mapNeteaseAlbums } from "./netease.js"; +import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js"; describe("NetEase adapter", () => { it("parses LRC format lyrics", () => { @@ -56,4 +56,40 @@ describe("NetEase adapter", () => { expect(mapNeteaseAlbums(null 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 }); + }); + + it("parseNeteaseTrial maps freeTrialInfo to trial seconds", () => { + // 无试听(VIP/免费) + expect(parseNeteaseTrial({})).toBeUndefined(); + expect(parseNeteaseTrial({ freeTrialInfo: null })).toBeUndefined(); + // 标准秒 + expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30 } })).toBe(30); + expect(parseNeteaseTrial({ freeTrialInfo: { start: 5, end: 35 } })).toBe(30); + // 别名容忍 begin/trialBegin + expect(parseNeteaseTrial({ freeTrialInfo: { begin: 0, end: 30 } })).toBe(30); + // 毫秒兜底(end>1000) + expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30000 } })).toBe(30); + // 异常 end<=start + expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined(); + }); }); diff --git a/src/music/netease.ts b/src/music/netease.ts index a9060b4..3c2d564 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios"; import type { MusicProvider, Song, + SongUrlResult, Playlist, PlaylistDetail, LyricLine, @@ -68,6 +69,33 @@ 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, + })); +} + +/** 解析网易云 freeTrialInfo → 试听秒数;无片段(VIP/免费)返回 undefined。 + * 真实字段 {start,end} 单位秒;容忍 begin/trialBegin 别名 + 毫秒兜底(end>1000)。 */ +export function parseNeteaseTrial(item: any): number | undefined { + const t = item?.freeTrialInfo; + if (!t || typeof t !== "object") return undefined; + const start = Number(t.start ?? t.begin ?? t.trialBegin ?? 0); + const end = Number(t.end ?? t.trialEnd); + if (!Number.isFinite(end) || end <= start) return undefined; + const secs = end > 1000 ? (end - start) / 1000 : end - start; + return Math.round(secs); +} + // NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster export const NETEASE_QUALITY_LEVELS = [ { value: "standard", label: "标准 (128kbps)", bitrate: 128 }, @@ -121,17 +149,7 @@ export class NeteaseProvider implements MusicProvider { }), ]); - 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 songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs); const playlists: Playlist[] = ( playlistRes.data?.result?.playlists ?? [] @@ -148,44 +166,29 @@ export class NeteaseProvider implements MusicProvider { return { songs, playlists, albums }; } - async getSongUrl(songId: string, quality?: string): Promise { + async getSongUrl(songId: string, quality?: string): Promise { const level = quality ?? this.quality; const res = await this.api.get("/song/url/v1", { params: { id: songId, level, ...this.cookieParams }, }); - return res.data?.data?.[0]?.url ?? null; + const item = res.data?.data?.[0]; + const url = item?.url; + if (!url) return null; + return { url, trialDuration: parseNeteaseTrial(item) }; } async getSongDetail(songId: string): Promise { 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", - }; + return mapNeteaseSongs(res.data?.songs)[0] ?? null; } async getPlaylistSongs(playlistId: string): Promise { 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", - })); + return mapNeteaseSongs(res.data?.songs); } async getRecommendPlaylists(): Promise { @@ -205,15 +208,7 @@ export class NeteaseProvider implements MusicProvider { 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", - })); + return mapNeteaseSongs(res.data?.songs); } async getLyrics(songId: string): Promise { @@ -312,30 +307,14 @@ export class NeteaseProvider implements MusicProvider { 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", - })); + return mapNeteaseSongs(res.data?.data); } async getDailyRecommendSongs(): Promise { 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", - })); + return mapNeteaseSongs(res.data?.data?.dailySongs); } async getPlaylistDetail(playlistId: string): Promise { diff --git a/src/music/provider.ts b/src/music/provider.ts index 566ffba..6317bbc 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -6,12 +6,22 @@ export interface Song { duration: number; // seconds coverUrl: string; 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 { url: string; } +/** getSongUrl 解析结果。trialDuration 缺省 = 完整可播放(VIP 账号 / 免费曲)。 */ +export interface SongUrlResult { + url: string; + /** 试听片段时长(秒)。VIP/免费曲为 undefined → 调用方回退完整 duration。 */ + trialDuration?: number; +} + export interface Playlist { id: string; name: string; @@ -65,7 +75,7 @@ export interface MusicProvider { readonly platform: "netease" | "qq" | "bilibili" | "youtube"; search(query: string, limit?: number): Promise; - getSongUrl(songId: string, quality?: string): Promise; + getSongUrl(songId: string, quality?: string): Promise; setQuality(quality: string): void; getQuality(): string; getSongDetail(songId: string): Promise; diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts index fb1c6ca..1d33538 100644 --- a/src/music/qq.test.ts +++ b/src/music/qq.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { mapQqAlbums, mapQqSongs } from "./qq.js"; +import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js"; describe("QQ adapter", () => { it("mapQqSongs maps QQMusicApi-style song entries", () => { @@ -22,10 +22,37 @@ describe("QQ adapter", () => { duration: 243, coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg", 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("parseQqTrial maps isTryout/tryout to trial seconds", () => { + // 非试听(VIP/免费) + expect(parseQqTrial({ isTryout: 0 })).toBeUndefined(); + expect(parseQqTrial({})).toBeUndefined(); + // 试听(秒) + expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30 })).toBe(30); + expect(parseQqTrial({ tryout: true, begin: 0, end: 45 })).toBe(45); + // 毫秒兜底 + expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30000 })).toBe(30); + // 异常 + expect(parseQqTrial({ isTryout: 1, tryEnd: 0 })).toBeUndefined(); + }); + it("mapQqAlbums maps albumMID-style raw entries", () => { const raw = [ { diff --git a/src/music/qq.ts b/src/music/qq.ts index 312ca8b..329ada6 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios"; import type { MusicProvider, Song, + SongUrlResult, Playlist, PlaylistDetail, LyricLine, @@ -53,10 +54,23 @@ export function mapQqSongs(raw: any[] | null | undefined): Song[] { ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg` : "", platform: "qq" as const, + vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false, }; }).filter((s) => s.id); } +/** 解析 QQ 试听标记 → 试听秒数;非试听(VIP/免费)返回 undefined。 + * 字段 isTryout===1 / tryout===true + tryBegin/tryEnd;容忍 begin/start 别名 + 毫秒兜底。 */ +export function parseQqTrial(playUrl: any): number | undefined { + if (!playUrl || typeof playUrl !== "object") return undefined; + if (playUrl.isTryout !== 1 && playUrl.tryout !== true) return undefined; + const begin = Number(playUrl.tryBegin ?? playUrl.begin ?? playUrl.start ?? 0); + const end = Number(playUrl.tryEnd ?? playUrl.end); + if (!Number.isFinite(end) || end <= begin) return undefined; + const secs = end > 1000 ? (end - begin) / 1000 : end - begin; + return Math.round(secs); +} + export function mapQqAlbums(raw: any[] | null | undefined): Album[] { if (!Array.isArray(raw)) return []; return raw.map((a) => { @@ -247,13 +261,13 @@ export class QQMusicProvider implements MusicProvider { return { songs, playlists: [], albums }; } - async getSongUrl(songId: string, quality?: string): Promise { + async getSongUrl(songId: string, quality?: string): Promise { try { const res = await this.api.get("/getMusicPlay", { params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams }, }); const playUrl = res.data?.data?.playUrl?.[songId]; - if (playUrl?.url) return playUrl.url; + if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) }; } catch { // try with songid try { @@ -261,7 +275,7 @@ export class QQMusicProvider implements MusicProvider { params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams }, }); const playUrl = res.data?.data?.playUrl?.[songId]; - if (playUrl?.url) return playUrl.url; + if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) }; } catch { // ignore } @@ -520,6 +534,7 @@ export class QQMusicProvider implements MusicProvider { ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` : "", platform: "qq", + vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false, })); } catch { return []; diff --git a/src/music/youtube.ts b/src/music/youtube.ts index 6ae6a34..209d057 100644 --- a/src/music/youtube.ts +++ b/src/music/youtube.ts @@ -7,6 +7,7 @@ import type { MusicProvider, Song, SongWithUrl, + SongUrlResult, Playlist, Album, SearchResult, @@ -134,7 +135,7 @@ export class YouTubeProvider implements MusicProvider { } } - async getSongUrl(songId: string): Promise { + async getSongUrl(songId: string): Promise { try { const url = `https://www.youtube.com/watch?v=${songId}`; const raw = await runYtDlp([ @@ -146,7 +147,7 @@ export class YouTubeProvider implements MusicProvider { "--quiet", ], 45_000); const audioUrl = raw.trim().split("\n")[0]; - return audioUrl || null; + return audioUrl ? { url: audioUrl } : null; } catch { return null; }