mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: resolve trial-only playback via trialDuration/effectiveDuration
VIP songs for non-VIP accounts return a ~30s trial fragment. The player used the full duration for isNearEnd, so the trial end didn't trigger auto-advance (~60s stall), and currentSong.duration stayed full, leaving the UI progress stuck.
- provider.ts: SongUrlResult {url, trialDuration?}; getSongUrl signature
- netease.ts: parseNeteaseTrial (freeTrialInfo start/end in seconds) + getSongUrl
- qq.ts: parseQqTrial (isTryout/tryEnd) + getSongUrl
- bilibili/youtube: getSongUrl returns {url}
- instance.ts: resolveAndPlay uses effectiveDuration = trialDuration ?? duration -> nearEnd at trial end -> native auto-advance; BotStatus.effectiveDuration
- VIP account: freeTrialInfo absent -> full duration -> full playback (no toggle)
Backward compatible (optional fields; getSongUrl has a single caller, updated).
Tests: parseTrial assertions (seconds/alias/ms-fallback). 14 pass.
This commit is contained in:
1 parent
7b2bd0ea6a
commit
fbb127a86d
8 files changed
+90
-16
No files matched your search
+11
-4
@@ -49,6 +49,8 @@ export interface BotStatus {
|
|||||||
volume: number;
|
volume: number;
|
||||||
playMode: PlayMode;
|
playMode: PlayMode;
|
||||||
elapsed: number; // ground truth elapsed seconds from frame count
|
elapsed: number; // ground truth elapsed seconds from frame count
|
||||||
|
/** 当前曲实际播放时长(秒)。试听片段=试听秒数;完整曲=duration。缺失时前端回退 currentSong.duration。 */
|
||||||
|
effectiveDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BotInstance extends EventEmitter {
|
export class BotInstance extends EventEmitter {
|
||||||
@@ -78,6 +80,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
private fmProvider: MusicProvider | null = null;
|
private fmProvider: MusicProvider | null = null;
|
||||||
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
||||||
private lastSearchResults: Song[] = [];
|
private lastSearchResults: Song[] = [];
|
||||||
|
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||||
|
private effectiveDuration: number | undefined;
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -460,8 +464,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.voteSkipUsers.clear();
|
this.voteSkipUsers.clear();
|
||||||
const provider = this.getProviderFor(song.platform);
|
const provider = this.getProviderFor(song.platform);
|
||||||
try {
|
try {
|
||||||
const url = await provider.getSongUrl(song.id);
|
const result = await provider.getSongUrl(song.id);
|
||||||
if (!url) {
|
if (!result?.url) {
|
||||||
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -477,8 +481,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
song.url = url;
|
song.url = result.url;
|
||||||
this.player.play(url, 0, song.duration);
|
// 试听片段用试听时长(让 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
|
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
||||||
// change won't try to "resume" a track the user already restarted.
|
// change won't try to "resume" a track the user already restarted.
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
@@ -1063,6 +1069,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
volume: this.player.getVolume(),
|
volume: this.player.getVolume(),
|
||||||
playMode: this.queue.getMode(),
|
playMode: this.queue.getMode(),
|
||||||
elapsed: this.player.getElapsed(),
|
elapsed: this.player.getElapsed(),
|
||||||
|
effectiveDuration: this.effectiveDuration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import axios, { type AxiosInstance } from "axios";
|
|||||||
import type {
|
import type {
|
||||||
MusicProvider,
|
MusicProvider,
|
||||||
Song,
|
Song,
|
||||||
|
SongUrlResult,
|
||||||
Playlist,
|
Playlist,
|
||||||
LyricLine,
|
LyricLine,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
@@ -231,7 +232,7 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
return this.cidCache.get(bvid) ?? null;
|
return this.cidCache.get(bvid) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
|
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
|
||||||
const cid = await this.getCid(songId);
|
const cid = await this.getCid(songId);
|
||||||
if (!cid) return null;
|
if (!cid) return null;
|
||||||
|
|
||||||
@@ -253,7 +254,8 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
(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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs } from "./netease.js";
|
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
|
||||||
|
|
||||||
describe("NetEase adapter", () => {
|
describe("NetEase adapter", () => {
|
||||||
it("parses LRC format lyrics", () => {
|
it("parses LRC format lyrics", () => {
|
||||||
@@ -77,4 +77,19 @@ describe("NetEase adapter", () => {
|
|||||||
]);
|
]);
|
||||||
expect(out[0]).toMatchObject({ artist: "B", album: "Al2", coverUrl: "p2", vip: false });
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+18
-2
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios";
|
|||||||
import type {
|
import type {
|
||||||
MusicProvider,
|
MusicProvider,
|
||||||
Song,
|
Song,
|
||||||
|
SongUrlResult,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistDetail,
|
PlaylistDetail,
|
||||||
LyricLine,
|
LyricLine,
|
||||||
@@ -83,6 +84,18 @@ export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析网易云 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
|
// 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 },
|
||||||
@@ -153,12 +166,15 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
return { songs, playlists, albums };
|
return { songs, playlists, albums };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||||
const level = quality ?? this.quality;
|
const level = quality ?? this.quality;
|
||||||
const res = await this.api.get("/song/url/v1", {
|
const res = await this.api.get("/song/url/v1", {
|
||||||
params: { id: songId, level, ...this.cookieParams },
|
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<Song | null> {
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export interface SongWithUrl extends Song {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** getSongUrl 解析结果。trialDuration 缺省 = 完整可播放(VIP 账号 / 免费曲)。 */
|
||||||
|
export interface SongUrlResult {
|
||||||
|
url: string;
|
||||||
|
/** 试听片段时长(秒)。VIP/免费曲为 undefined → 调用方回退完整 duration。 */
|
||||||
|
trialDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -68,7 +75,7 @@ export interface MusicProvider {
|
|||||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
|
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||||
|
|
||||||
search(query: string, limit?: number): Promise<SearchResult>;
|
search(query: string, limit?: number): Promise<SearchResult>;
|
||||||
getSongUrl(songId: string, quality?: string): Promise<string | null>;
|
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||||
setQuality(quality: string): void;
|
setQuality(quality: string): void;
|
||||||
getQuality(): string;
|
getQuality(): string;
|
||||||
getSongDetail(songId: string): Promise<Song | null>;
|
getSongDetail(songId: string): Promise<Song | null>;
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { mapQqAlbums, mapQqSongs } from "./qq.js";
|
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
|
||||||
|
|
||||||
describe("QQ adapter", () => {
|
describe("QQ adapter", () => {
|
||||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||||
@@ -40,6 +40,19 @@ describe("QQ adapter", () => {
|
|||||||
expect(out[3].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", () => {
|
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
||||||
const raw = [
|
const raw = [
|
||||||
{
|
{
|
||||||
|
|||||||
+16
-3
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios";
|
|||||||
import type {
|
import type {
|
||||||
MusicProvider,
|
MusicProvider,
|
||||||
Song,
|
Song,
|
||||||
|
SongUrlResult,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistDetail,
|
PlaylistDetail,
|
||||||
LyricLine,
|
LyricLine,
|
||||||
@@ -58,6 +59,18 @@ export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
|||||||
}).filter((s) => s.id);
|
}).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[] {
|
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.map((a) => {
|
return raw.map((a) => {
|
||||||
@@ -248,13 +261,13 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
return { songs, playlists: [], albums };
|
return { songs, playlists: [], albums };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||||
try {
|
try {
|
||||||
const res = await this.api.get("/getMusicPlay", {
|
const res = await this.api.get("/getMusicPlay", {
|
||||||
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
const playUrl = res.data?.data?.playUrl?.[songId];
|
const playUrl = res.data?.data?.playUrl?.[songId];
|
||||||
if (playUrl?.url) return playUrl.url;
|
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
|
||||||
} catch {
|
} catch {
|
||||||
// try with songid
|
// try with songid
|
||||||
try {
|
try {
|
||||||
@@ -262,7 +275,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
const playUrl = res.data?.data?.playUrl?.[songId];
|
const playUrl = res.data?.data?.playUrl?.[songId];
|
||||||
if (playUrl?.url) return playUrl.url;
|
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
MusicProvider,
|
MusicProvider,
|
||||||
Song,
|
Song,
|
||||||
SongWithUrl,
|
SongWithUrl,
|
||||||
|
SongUrlResult,
|
||||||
Playlist,
|
Playlist,
|
||||||
Album,
|
Album,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
@@ -134,7 +135,7 @@ export class YouTubeProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongUrl(songId: string): Promise<string | null> {
|
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
|
||||||
try {
|
try {
|
||||||
const url = `https://www.youtube.com/watch?v=${songId}`;
|
const url = `https://www.youtube.com/watch?v=${songId}`;
|
||||||
const raw = await runYtDlp([
|
const raw = await runYtDlp([
|
||||||
@@ -146,7 +147,7 @@ export class YouTubeProvider implements MusicProvider {
|
|||||||
"--quiet",
|
"--quiet",
|
||||||
], 45_000);
|
], 45_000);
|
||||||
const audioUrl = raw.trim().split("\n")[0];
|
const audioUrl = raw.trim().split("\n")[0];
|
||||||
return audioUrl || null;
|
return audioUrl ? { url: audioUrl } : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user