mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b82df4e50 | ||
|
|
8ff51ea6e0 | ||
|
|
3c0e8df763 | ||
|
|
2ea02f54d9 | ||
|
|
1407cadf7b | ||
|
|
9fd1b39092 |
No files matched your search
@@ -364,6 +364,7 @@ sudo systemctl start tsmusicbot
|
||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||
| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) |
|
||||
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
|
||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
|
||||
| `!fm` | 私人 FM(默认网易云,自动续播) |
|
||||
|
||||
@@ -55,13 +55,13 @@ describe("buildFfmpegArgs", () => {
|
||||
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
|
||||
});
|
||||
|
||||
it("inserts -ss before -i when seekSeconds > 0", () => {
|
||||
it("inserts -ss after -i when seekSeconds > 0", () => {
|
||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
||||
const ssIdx = args.indexOf("-ss");
|
||||
const iIdx = args.indexOf("-i");
|
||||
expect(ssIdx).toBeGreaterThan(-1);
|
||||
expect(args[ssIdx + 1]).toBe("42");
|
||||
expect(ssIdx).toBeLessThan(iIdx);
|
||||
expect(ssIdx).toBeGreaterThan(iIdx);
|
||||
});
|
||||
|
||||
it("does not insert -ss when seekSeconds is 0", () => {
|
||||
|
||||
+3
-1
@@ -103,8 +103,10 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||
"-reconnect_on_http_error", "4xx,5xx",
|
||||
);
|
||||
}
|
||||
args.push("-i", url);
|
||||
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
|
||||
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
||||
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -1538,3 +1538,122 @@ describe("BotInstance live-queue persistence (#119)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotInstance Bilibili multi-P resolution", () => {
|
||||
it("resolves multi-P search result to P1 with accurate duration and name", async () => {
|
||||
const multiPSongDetail = {
|
||||
id: "BV1multiP?p=1",
|
||||
name: "测试视频 - P1 分P1",
|
||||
artist: "UP主",
|
||||
album: "",
|
||||
duration: 100, // P1 duration
|
||||
coverUrl: "",
|
||||
platform: "bilibili" as const,
|
||||
};
|
||||
const mockBili = {
|
||||
platform: "bilibili" as const,
|
||||
search: vi.fn().mockResolvedValue({
|
||||
songs: [{
|
||||
id: "BV1multiP",
|
||||
name: "测试视频",
|
||||
artist: "UP主",
|
||||
album: "",
|
||||
duration: 300, // total duration in search
|
||||
coverUrl: "",
|
||||
platform: "bilibili",
|
||||
}],
|
||||
albums: [],
|
||||
playlists: [],
|
||||
}),
|
||||
getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail),
|
||||
getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
config: { commandPrefix: "!" },
|
||||
lastSearchResults: [] as any[],
|
||||
getProvider: () => mockBili,
|
||||
getProviderFor: () => mockBili,
|
||||
};
|
||||
|
||||
const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, {
|
||||
name: "play",
|
||||
args: "测试视频",
|
||||
rawArgs: ["测试视频"],
|
||||
flags: new Set(),
|
||||
});
|
||||
|
||||
expect(res.song).toBeDefined();
|
||||
expect(res.song.id).toBe("BV1multiP?p=1");
|
||||
expect(res.song.name).toBe("测试视频 - P1 分P1");
|
||||
expect(res.song.duration).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("cmdPlaylist with a playlist link (#160)", () => {
|
||||
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as (
|
||||
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> },
|
||||
) => Promise<string>;
|
||||
|
||||
function makeCtx() {
|
||||
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" };
|
||||
const makeProvider = (platform: string) => ({
|
||||
platform,
|
||||
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
|
||||
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
|
||||
});
|
||||
const providers: Record<string, any> = {
|
||||
netease: makeProvider("netease"),
|
||||
qq: makeProvider("qq"),
|
||||
youtube: makeProvider("youtube"),
|
||||
};
|
||||
const queued: any[] = [];
|
||||
return {
|
||||
providers,
|
||||
queued,
|
||||
getProvider: vi.fn(() => providers.netease),
|
||||
getProviderFor: vi.fn((p: string) => providers[p]),
|
||||
assertProviderEnabled: vi.fn(),
|
||||
extractId: (BotInstance.prototype as any).extractId,
|
||||
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId,
|
||||
player: { stop: vi.fn() },
|
||||
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] },
|
||||
disableFmMode: vi.fn(),
|
||||
withRequester: (s: any) => s,
|
||||
resolveAndPlay: vi.fn(async () => true),
|
||||
sweepLocalAudio: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
}
|
||||
const cmd = (args: string, flags: string[] = []) =>
|
||||
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
|
||||
|
||||
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => {
|
||||
const ctx = makeCtx();
|
||||
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]"));
|
||||
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267");
|
||||
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled();
|
||||
expect(ctx.queued[0].platform).toBe("qq");
|
||||
expect(reply).toMatch(/^Loaded 1 songs/);
|
||||
});
|
||||
|
||||
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => {
|
||||
const ctx = makeCtx();
|
||||
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123"));
|
||||
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123");
|
||||
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the link's platform is enabled", async () => {
|
||||
const ctx = makeCtx();
|
||||
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); });
|
||||
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用");
|
||||
});
|
||||
|
||||
it("keeps the old behavior for a bare id", async () => {
|
||||
const ctx = makeCtx();
|
||||
await cmdPlaylist.call(ctx, cmd("2829883282"));
|
||||
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
|
||||
});
|
||||
});
|
||||
+45
-6
@@ -13,7 +13,13 @@ import {
|
||||
canRunCommand,
|
||||
type ParsedCommand,
|
||||
} from "./commands.js";
|
||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
import {
|
||||
parseSongRef,
|
||||
parseSelectionIndex,
|
||||
parsePlaylistRef,
|
||||
findShareShortLink,
|
||||
resolveShareLink,
|
||||
} from "./song-ref.js";
|
||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
|
||||
@@ -968,6 +974,14 @@ export class BotInstance extends EventEmitter {
|
||||
this.voteSkipUsers.clear();
|
||||
const provider = this.getProviderFor(song.platform);
|
||||
try {
|
||||
if (song.platform === "bilibili" && (!song.id.includes("?p=") || song.duration === 0)) {
|
||||
const detail = await provider.getSongDetail(song.id);
|
||||
if (detail) {
|
||||
song.duration = detail.duration;
|
||||
song.name = detail.name;
|
||||
song.id = detail.id;
|
||||
}
|
||||
}
|
||||
const result = await provider.getSongUrl(song.id);
|
||||
if (!result?.url) {
|
||||
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
||||
@@ -1134,7 +1148,12 @@ export class BotInstance extends EventEmitter {
|
||||
return { error: `No recent search. Use ${p}search <name> first.` };
|
||||
if (sel > this.lastSearchResults.length)
|
||||
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
|
||||
return { song: this.lastSearchResults[sel - 1] };
|
||||
let song = this.lastSearchResults[sel - 1];
|
||||
if (song.platform === "bilibili") {
|
||||
const detail = await this.getProviderFor("bilibili").getSongDetail(song.id);
|
||||
if (detail) song = { ...detail, platform: "bilibili" };
|
||||
}
|
||||
return { song };
|
||||
}
|
||||
|
||||
// 2) id/URL — fetch that exact song.
|
||||
@@ -1151,7 +1170,12 @@ export class BotInstance extends EventEmitter {
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
const result = await provider.search(args, 1);
|
||||
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
|
||||
return { song: { ...result.songs[0], platform: provider.platform } };
|
||||
let song = result.songs[0];
|
||||
if (provider.platform === "bilibili") {
|
||||
const detail = await provider.getSongDetail(song.id);
|
||||
if (detail) song = detail;
|
||||
}
|
||||
return { song: { ...song, platform: provider.platform } };
|
||||
}
|
||||
|
||||
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
|
||||
@@ -1495,8 +1519,21 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
|
||||
|
||||
// A playlist link (#160) names its own platform, so it wins over flags.
|
||||
// App share short links are followed one hop to the real URL first.
|
||||
let ref = parsePlaylistRef(cmd.args);
|
||||
if (!ref) {
|
||||
const shortLink = findShareShortLink(cmd.args);
|
||||
if (shortLink) {
|
||||
const target = await resolveShareLink(shortLink);
|
||||
ref = target ? parsePlaylistRef(target) : null;
|
||||
if (!ref) return "Could not open that share link — paste the full playlist link or its ID instead";
|
||||
}
|
||||
}
|
||||
if (ref) this.assertProviderEnabled(ref.platform);
|
||||
const provider = ref ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
|
||||
|
||||
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
|
||||
const id = this.extractId(cmd.args);
|
||||
@@ -1504,7 +1541,9 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
let playlistId: string;
|
||||
|
||||
if (isDirectId || id !== cmd.args) {
|
||||
if (ref) {
|
||||
playlistId = ref.id;
|
||||
} else if (isDirectId || id !== cmd.args) {
|
||||
// Input is a direct ID or URL containing an ID — use existing logic
|
||||
playlistId = id;
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
|
||||
|
||||
describe("parseSongRef (#90 exact-song selection)", () => {
|
||||
it("returns null for a plain search term", () => {
|
||||
@@ -120,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
|
||||
expect(parseSelectionIndex("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
|
||||
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
|
||||
expect(parsePlaylistRef("华语经典")).toBeNull();
|
||||
expect(parsePlaylistRef("2829883282")).toBeNull();
|
||||
expect(parsePlaylistRef("")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
});
|
||||
|
||||
it("does not mistake a NetEase userid= for the playlist id", () => {
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
|
||||
});
|
||||
|
||||
it("parses QQ Music playlist URLs", () => {
|
||||
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
});
|
||||
|
||||
it("parses YouTube playlist URLs by their list= id", () => {
|
||||
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
|
||||
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
|
||||
});
|
||||
|
||||
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
|
||||
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
});
|
||||
|
||||
it("finds the link inside an app's share text", () => {
|
||||
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findShareShortLink (#160)", () => {
|
||||
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
|
||||
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
|
||||
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
|
||||
});
|
||||
|
||||
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
|
||||
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
|
||||
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
|
||||
expect(findShareShortLink("华语经典")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShareLink (#160)", () => {
|
||||
it("returns the redirect target", async () => {
|
||||
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
|
||||
});
|
||||
|
||||
it("returns null when there is no redirect or the request fails", async () => {
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
|
||||
* so same-name songs can be disambiguated instead of always getting the single
|
||||
@@ -98,3 +100,82 @@ export function parseSelectionIndex(raw: string): number | null {
|
||||
const n = parseInt(m[1], 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
export interface PlaylistRef {
|
||||
id: string;
|
||||
platform: "netease" | "qq" | "youtube";
|
||||
}
|
||||
|
||||
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
|
||||
function stripUrlBBCode(text: string): string {
|
||||
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
|
||||
* share text. The platform comes from the URL, so a QQ link works without
|
||||
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
|
||||
* the caller handles as before.
|
||||
*/
|
||||
export function parsePlaylistRef(raw: string): PlaylistRef | null {
|
||||
const q = stripUrlBBCode(raw ?? "").trim();
|
||||
if (!q) return null;
|
||||
|
||||
if (/music\.163\.com/i.test(q)) {
|
||||
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "netease" };
|
||||
}
|
||||
|
||||
if (/y\.qq\.com/i.test(q)) {
|
||||
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "qq" };
|
||||
}
|
||||
|
||||
if (/youtube\.com|youtu\.be/i.test(q)) {
|
||||
const m = /[?&]list=([\w-]+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "youtube" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
|
||||
* short link — what the phone apps copy. Only these hosts are recognized so
|
||||
* the bot never fetches an arbitrary user-supplied URL.
|
||||
*/
|
||||
export function findShareShortLink(raw: string): string | null {
|
||||
const q = stripUrlBBCode(raw ?? "");
|
||||
const m =
|
||||
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
|
||||
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
|
||||
|
||||
const redirectGet: RedirectGet = async (url) => {
|
||||
const res = await axios.get(url, {
|
||||
maxRedirects: 0,
|
||||
timeout: 5000,
|
||||
validateStatus: () => true,
|
||||
responseType: "stream",
|
||||
});
|
||||
res.data?.destroy?.();
|
||||
const location = res.headers.location;
|
||||
return { status: res.status, location: typeof location === "string" ? location : undefined };
|
||||
};
|
||||
|
||||
/** Follow a share short link one hop. Returns the target URL, or null. */
|
||||
export async function resolveShareLink(
|
||||
url: string,
|
||||
get: RedirectGet = redirectGet,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { status, location } = await get(url);
|
||||
if (status < 300 || status >= 400 || !location) return null;
|
||||
return new URL(location, url).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,128 @@ describe("BiliBiliProvider.search pagination", () => {
|
||||
expect(searchParams(get).page).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BiliBiliProvider multi-P support", () => {
|
||||
it("parseBilibiliId extracts bvid and page correctly", async () => {
|
||||
const { parseBilibiliId } = await import("./bilibili.js");
|
||||
expect(parseBilibiliId("BV1yxHQeYEuE")).toEqual({ bvid: "BV1yxHQeYEuE", page: 1 });
|
||||
expect(parseBilibiliId("BV1yxHQeYEuE?p=3")).toEqual({ bvid: "BV1yxHQeYEuE", page: 3 });
|
||||
expect(parseBilibiliId("BV1yxHQeYEuE:p2")).toEqual({ bvid: "BV1yxHQeYEuE", page: 2 });
|
||||
expect(parseBilibiliId("https://www.bilibili.com/video/BV1yxHQeYEuE?p=5")).toEqual({
|
||||
bvid: "BV1yxHQeYEuE",
|
||||
page: 5,
|
||||
});
|
||||
expect(parseBilibiliId("some-other-id")).toEqual({ bvid: "some-other-id", page: 1 });
|
||||
});
|
||||
|
||||
function mockViewProvider(viewData: any, playUrlData?: any) {
|
||||
const p = new BiliBiliProvider();
|
||||
const get = vi.fn().mockImplementation((url: string, opts?: any) => {
|
||||
if (url === "/x/web-interface/view") {
|
||||
return Promise.resolve({ data: { data: viewData } });
|
||||
}
|
||||
if (url === "/x/player/playurl") {
|
||||
return Promise.resolve({ data: { data: playUrlData ?? {} } });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
(p as any).buvidInitialized = true;
|
||||
(p as any).api = { get };
|
||||
return { p, get };
|
||||
}
|
||||
|
||||
const multiPViewData = {
|
||||
bvid: "BV1multiP",
|
||||
title: "测试多P教程",
|
||||
pic: "http://i0.hdslb.com/bfs/archive/test.jpg",
|
||||
duration: 300, // 总时长 300 秒 (120 + 180)
|
||||
owner: { name: "UP主测试" },
|
||||
pages: [
|
||||
{ cid: 10001, page: 1, part: "第一讲 入门", duration: 120 },
|
||||
{ cid: 10002, page: 2, part: "第二讲 进阶", duration: 180 },
|
||||
],
|
||||
};
|
||||
|
||||
const singlePViewData = {
|
||||
bvid: "BV1singleP",
|
||||
title: "测试单P视频",
|
||||
pic: "http://i0.hdslb.com/bfs/archive/single.jpg",
|
||||
duration: 200,
|
||||
owner: { name: "UP主测试" },
|
||||
pages: [
|
||||
{ cid: 20001, page: 1, part: "测试单P视频", duration: 200 },
|
||||
],
|
||||
};
|
||||
|
||||
it("getSongDetail for single-P video returns total duration and clean bvid", async () => {
|
||||
const { p } = mockViewProvider(singlePViewData);
|
||||
const song = await p.getSongDetail("BV1singleP");
|
||||
expect(song).not.toBeNull();
|
||||
expect(song!.id).toBe("BV1singleP");
|
||||
expect(song!.name).toBe("测试单P视频");
|
||||
expect(song!.duration).toBe(200);
|
||||
expect(song!.platform).toBe("bilibili");
|
||||
});
|
||||
|
||||
it("getSongDetail for multi-P video without ?p defaults to P1 with P1 duration", async () => {
|
||||
const { p } = mockViewProvider(multiPViewData);
|
||||
const song = await p.getSongDetail("BV1multiP");
|
||||
expect(song).not.toBeNull();
|
||||
expect(song!.id).toBe("BV1multiP?p=1");
|
||||
expect(song!.name).toBe("测试多P教程 - P1 第一讲 入门");
|
||||
expect(song!.duration).toBe(120); // P1 独立时长,而非总时长 300!
|
||||
expect(song!.platform).toBe("bilibili");
|
||||
});
|
||||
|
||||
it("getSongDetail for multi-P video with ?p=2 returns P2 with P2 duration", async () => {
|
||||
const { p } = mockViewProvider(multiPViewData);
|
||||
const song = await p.getSongDetail("BV1multiP?p=2");
|
||||
expect(song).not.toBeNull();
|
||||
expect(song!.id).toBe("BV1multiP?p=2");
|
||||
expect(song!.name).toBe("测试多P教程 - P2 第二讲 进阶");
|
||||
expect(song!.duration).toBe(180); // P2 独立时长
|
||||
expect(song!.platform).toBe("bilibili");
|
||||
});
|
||||
|
||||
it("getVideoParts returns all parts with duration and cid", async () => {
|
||||
const { p } = mockViewProvider(multiPViewData);
|
||||
const partsResult = await p.getVideoParts("BV1multiP");
|
||||
expect(partsResult).not.toBeNull();
|
||||
expect(partsResult!.bvid).toBe("BV1multiP");
|
||||
expect(partsResult!.title).toBe("测试多P教程");
|
||||
expect(partsResult!.parts).toHaveLength(2);
|
||||
expect(partsResult!.parts[0]).toEqual({
|
||||
part: 1,
|
||||
cid: 10001,
|
||||
title: "第一讲 入门",
|
||||
duration: 120,
|
||||
});
|
||||
expect(partsResult!.parts[1]).toEqual({
|
||||
part: 2,
|
||||
cid: 10002,
|
||||
title: "第二讲 进阶",
|
||||
duration: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it("getSongUrl requests playurl with correct cid for specific part", async () => {
|
||||
const playUrlResponse = {
|
||||
dash: {
|
||||
audio: [
|
||||
{ bandwidth: 64000, baseUrl: "http://audio.64k.test" },
|
||||
{ bandwidth: 320000, baseUrl: "http://audio.320k.test" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const { p, get } = mockViewProvider(multiPViewData, playUrlResponse);
|
||||
|
||||
const result = await p.getSongUrl("BV1multiP?p=2");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.url).toBe("http://audio.320k.test");
|
||||
|
||||
const playurlCall = get.mock.calls.find((c: any[]) => c[0] === "/x/player/playurl");
|
||||
expect(playurlCall).toBeTruthy();
|
||||
expect(playurlCall![1].params.cid).toBe(10002); // 准确传入 P2 的 cid
|
||||
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
|
||||
});
|
||||
});
|
||||
+106
-11
@@ -27,6 +27,38 @@ const WBI_MIXIN_KEY_ENC_TAB = [
|
||||
|
||||
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
|
||||
|
||||
export interface BiliVideoPart {
|
||||
part: number;
|
||||
cid: number;
|
||||
title: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface BiliVideoPartsResult {
|
||||
bvid: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
artist: string;
|
||||
parts: BiliVideoPart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析带有分P信息的 B站 ID 或 URL。
|
||||
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
|
||||
*/
|
||||
export function parseBilibiliId(songId: string): { bvid: string; page: number } {
|
||||
const str = (songId ?? "").trim();
|
||||
const bvMatch = str.match(/BV[0-9A-Za-z]+/i);
|
||||
if (!bvMatch) {
|
||||
return { bvid: str, page: 1 };
|
||||
}
|
||||
const bvid = bvMatch[0];
|
||||
const pageMatch = str.match(/[?&]p=(\d+)|:p?(\d+)/i);
|
||||
const pageStr = pageMatch ? (pageMatch[1] ?? pageMatch[2]) : undefined;
|
||||
const page = pageStr ? parseInt(pageStr, 10) : 1;
|
||||
return { bvid, page: Math.max(1, page) };
|
||||
}
|
||||
|
||||
export class BiliBiliProvider implements MusicProvider {
|
||||
readonly platform = "bilibili" as const;
|
||||
private api: AxiosInstance;
|
||||
@@ -192,18 +224,41 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const { bvid, page } = parseBilibiliId(songId);
|
||||
try {
|
||||
const res = await this.api.get("/x/web-interface/view", {
|
||||
params: { bvid: songId },
|
||||
params: { bvid },
|
||||
headers: this.cookieHeaders,
|
||||
});
|
||||
|
||||
const data = res.data?.data;
|
||||
if (!data) return null;
|
||||
|
||||
// Cache cid for later audio URL fetching
|
||||
if (data.pages?.[0]?.cid) {
|
||||
this.cidCache.set(songId, data.pages[0].cid);
|
||||
const pages = data.pages ?? [];
|
||||
// 缓存所有分P的 cid 映射
|
||||
for (const p of pages) {
|
||||
this.cidCache.set(`${bvid}?p=${p.page}`, p.cid);
|
||||
}
|
||||
if (pages[0]?.cid) {
|
||||
this.cidCache.set(bvid, pages[0].cid);
|
||||
}
|
||||
|
||||
const targetPage = pages.find((p: any) => p.page === page) ?? pages[0];
|
||||
|
||||
// 若为多P视频,返回对应分P的名称与独立时长
|
||||
if (pages.length > 1 && targetPage) {
|
||||
const partTitle = targetPage.part && targetPage.part !== data.title
|
||||
? `${data.title} - P${targetPage.page} ${targetPage.part}`
|
||||
: `${data.title} (P${targetPage.page})`;
|
||||
return {
|
||||
id: `${bvid}?p=${targetPage.page}`,
|
||||
name: partTitle,
|
||||
artist: data.owner?.name ?? "",
|
||||
album: "",
|
||||
duration: targetPage.duration ?? 0,
|
||||
coverUrl: this.normalizeCover(data.pic ?? ""),
|
||||
platform: "bilibili" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -211,7 +266,7 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
name: data.title ?? "",
|
||||
artist: data.owner?.name ?? "",
|
||||
album: "",
|
||||
duration: data.duration ?? 0,
|
||||
duration: targetPage?.duration ?? data.duration ?? 0,
|
||||
coverUrl: this.normalizeCover(data.pic ?? ""),
|
||||
platform: "bilibili" as const,
|
||||
};
|
||||
@@ -220,9 +275,47 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取视频所有分P列表 */
|
||||
async getVideoParts(bvid: string): Promise<BiliVideoPartsResult | null> {
|
||||
const { bvid: cleanBvid } = parseBilibiliId(bvid);
|
||||
try {
|
||||
const res = await this.api.get("/x/web-interface/view", {
|
||||
params: { bvid: cleanBvid },
|
||||
headers: this.cookieHeaders,
|
||||
});
|
||||
|
||||
const data = res.data?.data;
|
||||
if (!data) return null;
|
||||
|
||||
const pages = data.pages ?? [];
|
||||
for (const p of pages) {
|
||||
this.cidCache.set(`${cleanBvid}?p=${p.page}`, p.cid);
|
||||
}
|
||||
if (pages[0]?.cid) {
|
||||
this.cidCache.set(cleanBvid, pages[0].cid);
|
||||
}
|
||||
|
||||
return {
|
||||
bvid: cleanBvid,
|
||||
title: data.title ?? "",
|
||||
coverUrl: this.normalizeCover(data.pic ?? ""),
|
||||
artist: data.owner?.name ?? "",
|
||||
parts: pages.map((p: any) => ({
|
||||
part: p.page,
|
||||
cid: p.cid,
|
||||
title: p.part ?? `P${p.page}`,
|
||||
duration: p.duration ?? 0,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get CID for a bvid, using cache when available */
|
||||
private async getCid(bvid: string): Promise<number | null> {
|
||||
const cached = this.cidCache.get(bvid);
|
||||
private async getCid(bvid: string, page = 1): Promise<number | null> {
|
||||
const key = page > 1 ? `${bvid}?p=${page}` : bvid;
|
||||
const cached = this.cidCache.get(key) ?? (page === 1 ? this.cidCache.get(`${bvid}?p=1`) : undefined);
|
||||
if (cached) return cached;
|
||||
|
||||
// Limit cache size to prevent unbounded growth
|
||||
@@ -231,20 +324,22 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
if (firstKey) this.cidCache.delete(firstKey);
|
||||
}
|
||||
|
||||
const detail = await this.getSongDetail(bvid);
|
||||
const songId = page > 1 ? `${bvid}?p=${page}` : bvid;
|
||||
const detail = await this.getSongDetail(songId);
|
||||
if (!detail) return null;
|
||||
return this.cidCache.get(bvid) ?? null;
|
||||
return this.cidCache.get(key) ?? this.cidCache.get(`${bvid}?p=${page}`) ?? this.cidCache.get(bvid) ?? null;
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
|
||||
const cid = await this.getCid(songId);
|
||||
const { bvid, page } = parseBilibiliId(songId);
|
||||
const cid = await this.getCid(bvid, page);
|
||||
if (!cid) return null;
|
||||
|
||||
try {
|
||||
const res = await this.api.get("/x/player/playurl", {
|
||||
params: {
|
||||
cid,
|
||||
bvid: songId,
|
||||
bvid,
|
||||
fnval: 16, // DASH format
|
||||
},
|
||||
headers: this.cookieHeaders,
|
||||
|
||||
@@ -438,3 +438,68 @@ describe("music router POST /local/upload — content types and size cap (#149)"
|
||||
expect(uploadAudio).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("music router GET /bilibili/parts", () => {
|
||||
it("returns 400 when bvid is missing", async () => {
|
||||
const router = createMusicRouter(
|
||||
fakeProvider("netease"),
|
||||
fakeProvider("qq"),
|
||||
fakeProvider("bilibili"),
|
||||
pino({ level: "silent" })
|
||||
);
|
||||
const app = express();
|
||||
app.use("/api/music", router);
|
||||
|
||||
const res = await request(app).get("/api/music/bilibili/parts");
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("bvid is required");
|
||||
});
|
||||
|
||||
it("returns parts from bilibili provider when available", async () => {
|
||||
const mockBilibili = {
|
||||
platform: "bilibili" as const,
|
||||
search: vi.fn(),
|
||||
getVideoParts: vi.fn().mockResolvedValue({
|
||||
bvid: "BV1test",
|
||||
title: "多P视频测试",
|
||||
parts: [
|
||||
{ part: 1, cid: 101, title: "P1", duration: 100 },
|
||||
{ part: 2, cid: 102, title: "P2", duration: 200 },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const router = createMusicRouter(
|
||||
fakeProvider("netease"),
|
||||
fakeProvider("qq"),
|
||||
mockBilibili as unknown as MusicProvider,
|
||||
pino({ level: "silent" })
|
||||
);
|
||||
const app = express();
|
||||
app.use("/api/music", router);
|
||||
|
||||
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bvid).toBe("BV1test");
|
||||
expect(res.body.parts).toHaveLength(2);
|
||||
expect(mockBilibili.getVideoParts).toHaveBeenCalledWith("BV1test");
|
||||
});
|
||||
|
||||
it("returns 404 when getVideoParts returns null", async () => {
|
||||
const mockBilibili = {
|
||||
platform: "bilibili" as const,
|
||||
search: vi.fn(),
|
||||
getVideoParts: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
const router = createMusicRouter(
|
||||
fakeProvider("netease"),
|
||||
fakeProvider("qq"),
|
||||
mockBilibili as unknown as MusicProvider,
|
||||
pino({ level: "silent" })
|
||||
);
|
||||
const app = express();
|
||||
app.use("/api/music", router);
|
||||
|
||||
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1notfound");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -382,6 +382,31 @@ export function createMusicRouter(
|
||||
}
|
||||
});
|
||||
|
||||
// B站分P列表查询
|
||||
router.get("/bilibili/parts", async (req, res) => {
|
||||
try {
|
||||
const bvid = (req.query.bvid as string)?.trim();
|
||||
if (!bvid) {
|
||||
res.status(400).json({ error: "bvid is required" });
|
||||
return;
|
||||
}
|
||||
const provider = bilibiliProvider as any;
|
||||
if (typeof provider.getVideoParts === "function") {
|
||||
const result = await provider.getVideoParts(bvid);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Video not found" });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(501).json({ error: "Not supported" });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Get bilibili parts failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Enabled sources + default platform, for the web UI (source tabs, default
|
||||
// search/playback source). Without a config (unit-test routers) everything
|
||||
// reports enabled with the legacy netease default.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<Player />
|
||||
<Toast />
|
||||
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
|
||||
<BilibiliPartModal />
|
||||
|
||||
<!-- Mobile mini player -->
|
||||
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
|
||||
@@ -102,6 +103,7 @@ import Player from './components/Player.vue';
|
||||
import CoverArt from './components/CoverArt.vue';
|
||||
import Toast from './components/Toast.vue';
|
||||
import Queue from './components/Queue.vue';
|
||||
import BilibiliPartModal from './components/BilibiliPartModal.vue';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const session = useSession();
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div v-if="modal.open" class="edit-modal-overlay" @click.self="store.closeBilibiliPartModal">
|
||||
<div class="edit-modal">
|
||||
<h3 class="modal-title">选择分P</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>视频名称</label>
|
||||
<div class="video-info-box">
|
||||
<CoverArt :url="modal.coverUrl" :size="44" :radius="8" />
|
||||
<div class="video-meta">
|
||||
<div class="video-title" :title="modal.title">{{ modal.title }}</div>
|
||||
<div class="video-hint">共 {{ modal.parts.length }} 个分P · {{ actionHint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>分P列表</label>
|
||||
<div class="parts-list">
|
||||
<div
|
||||
v-for="part in modal.parts"
|
||||
:key="part.part"
|
||||
class="part-item"
|
||||
:class="{ active: selectedPart?.part === part.part }"
|
||||
@click="selectedPart = part"
|
||||
@dblclick="confirmSelect(part)"
|
||||
>
|
||||
<span class="part-badge">P{{ part.part }}</span>
|
||||
<span class="part-name" :title="part.title">{{ part.title }}</span>
|
||||
<span class="part-duration">{{ formatDuration(part.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="store.closeBilibiliPartModal">取消</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="!selectedPart"
|
||||
@click="selectedPart && confirmSelect(selectedPart)"
|
||||
>
|
||||
{{ confirmBtnText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { usePlayerStore, type BiliPart } from '../stores/player.js';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const modal = computed(() => store.biliPartModal);
|
||||
|
||||
const selectedPart = ref<BiliPart | null>(null);
|
||||
|
||||
// 弹窗打开时默认选中第 1 P
|
||||
watch(
|
||||
() => modal.value.open,
|
||||
(open) => {
|
||||
if (open && modal.value.parts.length > 0) {
|
||||
selectedPart.value = modal.value.parts[0];
|
||||
} else {
|
||||
selectedPart.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const actionHint = computed(() => {
|
||||
if (modal.value.action === 'playNext') return '添加到下一首播放';
|
||||
if (modal.value.action === 'add') return '添加到播放队列';
|
||||
return '立即播放';
|
||||
});
|
||||
|
||||
const confirmBtnText = computed(() => {
|
||||
if (modal.value.action === 'playNext') return '下一首播放';
|
||||
if (modal.value.action === 'add') return '添加到队列';
|
||||
return '播放';
|
||||
});
|
||||
|
||||
function confirmSelect(part: BiliPart) {
|
||||
store.selectBilibiliPart(part);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return '--:--';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.edit-modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.7;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.video-info-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
.video-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.video-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.parts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.part-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-10, rgba(0, 161, 214, 0.08));
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-15, rgba(0, 161, 214, 0.15));
|
||||
|
||||
.part-badge {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.part-name {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.part-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-primary-10, rgba(0, 161, 214, 0.1));
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.part-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.part-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 20px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+107
-3
@@ -17,6 +17,24 @@ export interface Song {
|
||||
|
||||
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||
|
||||
export interface BiliPart {
|
||||
part: number;
|
||||
cid: number;
|
||||
title: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface BiliPartModalState {
|
||||
open: boolean;
|
||||
song: Song | null;
|
||||
action: 'play' | 'playNext' | 'add';
|
||||
bvid: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
artist: string;
|
||||
parts: BiliPart[];
|
||||
}
|
||||
|
||||
export interface AlbumItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -138,6 +156,18 @@ export const usePlayerStore = defineStore('player', {
|
||||
// Transient notification for surfacing failures (e.g., "song not playable")
|
||||
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
||||
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
||||
|
||||
// Bilibili 多P分P选择弹窗状态
|
||||
biliPartModal: {
|
||||
open: false,
|
||||
song: null,
|
||||
action: 'play',
|
||||
bvid: '',
|
||||
title: '',
|
||||
coverUrl: '',
|
||||
artist: '',
|
||||
parts: [] as BiliPart[],
|
||||
} as BiliPartModalState,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -411,8 +441,74 @@ export const usePlayerStore = defineStore('player', {
|
||||
this.notification = { id: Date.now(), message, type };
|
||||
},
|
||||
|
||||
async playSong(song: Song) {
|
||||
/**
|
||||
* 检查 B站视频是否为多P,若为多P则弹窗询问,单P则直接修正时长并继续
|
||||
*/
|
||||
async checkBilibiliMultiPart(song: Song, action: 'play' | 'playNext' | 'add'): Promise<boolean> {
|
||||
try {
|
||||
const cleanBvid = song.id.split('?')[0].split(':')[0];
|
||||
const res = await axios.get('/api/music/bilibili/parts', { params: { bvid: cleanBvid } });
|
||||
const parts: BiliPart[] = res.data?.parts ?? [];
|
||||
if (parts.length > 1) {
|
||||
this.biliPartModal = {
|
||||
open: true,
|
||||
song,
|
||||
action,
|
||||
bvid: cleanBvid,
|
||||
title: res.data.title || song.name,
|
||||
coverUrl: res.data.coverUrl || song.coverUrl,
|
||||
artist: res.data.artist || song.artist,
|
||||
parts,
|
||||
};
|
||||
return true; // 弹窗接管
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
song.duration = parts[0].duration;
|
||||
}
|
||||
} catch {
|
||||
// 网络请求异常则降级为正常播放
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
selectBilibiliPart(part: BiliPart) {
|
||||
if (!this.biliPartModal.open || !this.biliPartModal.song) return;
|
||||
const { song, action, bvid, title, artist, coverUrl } = this.biliPartModal;
|
||||
this.biliPartModal.open = false;
|
||||
|
||||
const partTitle = part.title && part.title !== title
|
||||
? `${title} - P${part.part} ${part.title}`
|
||||
: `${title} (P${part.part})`;
|
||||
|
||||
const partSong: Song = {
|
||||
...song,
|
||||
id: `${bvid}?p=${part.part}`,
|
||||
name: partTitle,
|
||||
artist: artist || song.artist,
|
||||
coverUrl: coverUrl || song.coverUrl,
|
||||
duration: part.duration,
|
||||
};
|
||||
|
||||
if (action === 'play') {
|
||||
this.playSong(partSong, true);
|
||||
} else if (action === 'playNext') {
|
||||
this.playNextSong(partSong, true);
|
||||
} else if (action === 'add') {
|
||||
this.addSong(partSong, true);
|
||||
}
|
||||
},
|
||||
|
||||
closeBilibiliPartModal() {
|
||||
this.biliPartModal.open = false;
|
||||
this.biliPartModal.song = null;
|
||||
},
|
||||
|
||||
async playSong(song: Song, skipPartCheck = false) {
|
||||
if (!this.activeBotId) return;
|
||||
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||
const handled = await this.checkBilibiliMultiPart(song, 'play');
|
||||
if (handled) return;
|
||||
}
|
||||
// Guests use the non-destructive "play now" (insert-next + skip) so they
|
||||
// can't wipe everyone else's queue; members/admins keep the normal behavior.
|
||||
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
|
||||
@@ -424,8 +520,12 @@ export const usePlayerStore = defineStore('player', {
|
||||
this._syncAfterAction();
|
||||
},
|
||||
|
||||
async playNextSong(song: Song) {
|
||||
async playNextSong(song: Song, skipPartCheck = false) {
|
||||
if (!this.activeBotId) return;
|
||||
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||
const handled = await this.checkBilibiliMultiPart(song, 'playNext');
|
||||
if (handled) return;
|
||||
}
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
@@ -444,8 +544,12 @@ export const usePlayerStore = defineStore('player', {
|
||||
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
|
||||
},
|
||||
|
||||
async addSong(song: Song) {
|
||||
async addSong(song: Song, skipPartCheck = false) {
|
||||
if (!this.activeBotId) return;
|
||||
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||
const handled = await this.checkBilibiliMultiPart(song, 'add');
|
||||
if (handled) return;
|
||||
}
|
||||
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user