diff --git a/README.md b/README.md index 92fa360..2ff07f0 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环 - **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步 -- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单 +- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单。多人共用时,每个网页端用户可在 **设置 → 账户** 扫码绑定**自己的网易云账号**,之后他在网页端开启的网易云私人 FM 按他自己的口味推荐(未绑定则用机器人的共享账号;TS 聊天里的 `!fm` 仍用共享账号) - **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带 - **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放 - **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确) @@ -136,14 +136,35 @@ ports: -### 方式四:Linux 一键安装 +### 方式四:Linux 安装脚本 + +Linux 下有两个脚本,按需二选一: + +| | `scripts/install.sh`(一键安装 + 系统服务) | `scripts/setup.sh`(只安装构建) | +|---|---|---| +| 适合 | 想开箱即用、开机自启的服务器 | 想自己决定怎么常驻(screen / tmux / pm2 / 自写服务)的用户,或 macOS | +| Node.js | 没有或版本过低时**自动安装 Node 22 LTS**(apt / yum / pacman) | **不会安装**,需先自行装好 Node 22.12+ | +| 系统依赖 | 自动安装构建工具(和 FFmpeg,作为内置 FFmpeg 的后备) | 不安装,只提示 | +| 安装位置 | 构建后复制到 `/opt/tsmusicbot`(重装时保留 `data/`) | 就在当前项目目录 | +| 系统服务 | 自动配置 systemd 服务 `tsmusicbot` 并开机自启 | **不配置服务**,完成后自己 `npm start` | +| 需要 root | 是(`sudo`) | 否 | + +两者共用同一套安装逻辑:`install.sh` 会调用 `setup.sh` 完成依赖安装、国内网络镜像切换、原生模块校验和构建,然后再复制文件、配置服务。 + +**一键安装 + systemd 服务:** ```bash chmod +x scripts/install.sh sudo ./scripts/install.sh +# 之后:systemctl status|restart|stop tsmusicbot,日志:journalctl -u tsmusicbot -f ``` -自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。 +**只安装构建(不装 Node、不配服务):** + +```bash +bash scripts/setup.sh +npm start +``` ## 更新升级 @@ -364,6 +385,7 @@ sudo systemctl start tsmusicbot | `!mode ` | 切换播放模式 | | `!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(默认网易云,自动续播) | @@ -541,6 +563,7 @@ teamspeak-music-bot/ ├── scripts/ # 部署脚本 │ ├── setup.bat # Windows 首次安装 │ ├── start.bat # Windows 启动脚本 +│ ├── setup.sh # Linux/macOS 首次安装(只安装构建) │ ├── install.sh # Linux 一键安装 + systemd 服务 │ └── docker/ # Docker 部署文件 │ ├── Dockerfile @@ -930,7 +953,36 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部 > 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。 -### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机 +### 最新版本 — v1.14.0:歌单链接直接播放 / 每人绑定自己的网易云私人FM / B站分P + +处理了 5 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;数据库会自动新增一张表(存放用户自己绑定的网易云账号),原有数据不受影响。 + +**`!playlist` 直接粘贴歌单链接([#160](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/160),[PR #169](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/169),感谢 [@JiaxiangACE](https://github.com/JiaxiangACE))** + +- `!playlist <歌单链接>` 支持网易云 / QQ 音乐 / YouTube 歌单链接,**平台由链接自动识别**:以前 QQ 链接不加 `-q` 会被拿去网易云查,YouTube 的 `?list=` 链接会被当成歌单名去搜索,现在都能直接用。 +- App 里「分享」复制出来的整段文案、以及短链(`163cn.tv`、`c6.y.qq.com`)也能直接粘贴。短链只会访问这两个域名,不会去请求任意用户给的地址。 +- 歌单名和纯数字 ID 的用法不变。 + +**每个网页端用户绑定自己的网易云账号听私人FM([#164](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/164),[PR #171](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/171),感谢 [@xxmod](https://github.com/xxmod))** + +- 多人共用一个机器人时,私人FM以前永远按机器人登录的那一个账号推荐。现在每个成员可以在 **设置 → 账户** 扫码绑定自己的网易云账号,之后他在网页端开启的网易云私人FM按他自己的口味推荐;未绑定的人照旧使用共享账号。 +- 绑定的登录只保存在服务器上,从不回传给浏览器,也**不会**顶掉机器人的共享登录;删除用户时一并清除。游客不能绑定。 +- TS 聊天里的 `!fm` 仍使用共享账号(聊天里的 TS 用户和网页账号没有对应关系)。 + +**机器人被移动后,原频道描述不再残留([#159](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/159),[PR #168](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/168),感谢 [@Almighty-ap](https://github.com/Almighty-ap))** + +- 开启「更新频道描述」时,把机器人拖到别的频道后,原频道会一直停留在当时的歌曲信息。现在机器人被移动时会清空原频道描述,并把正在播放的信息写到新频道。 + +**Linux 安装脚本([#165](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/165),[PR #172](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/172),感谢 [@XuVIIJay](https://github.com/XuVIIJay))** + +- `scripts/install.sh` 以前仍在安装已不再支持的 Node 20,现在按发行版(apt / yum / pacman)安装 Node 22 LTS,并复用 `setup.sh` 完成依赖安装、国内镜像切换、原生模块校验和构建。重复运行(升级)时会先停服务、替换构建产物,**保留 `data/`**。 +- README 的「Linux 安装脚本」一节说明了 `install.sh`(一键安装 + systemd 开机自启)和 `setup.sh`(只安装构建、不装 Node、不配服务)的区别和适用场景。 + +**B站分P视频([PR #166](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/166),感谢 [@xxmod](https://github.com/xxmod))** + +- 分P视频以前只能播放第一P,且时长显示为整个视频的总时长。现在网页端播放多P视频时会弹出选择框选P;TS 里 `!play` 播放第一P。 + +### v1.13.0:本地视频上传播放 / 头像上传时机 处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。 diff --git a/scripts/install.sh b/scripts/install.sh index 4715d15..9a944cb 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash set -euo pipefail +# +# TSMusicBot Installer (Linux, systemd) +# - Installs system packages and Node.js 22 LTS +# - Runs scripts/setup.sh to install dependencies, verify native binaries and build +# - Copies the build to /opt/tsmusicbot and registers a systemd service (auto-start on boot) +# +# Only want to build and run it yourself (no Node install, no service)? +# Use scripts/setup.sh instead — see README「Linux 安装脚本」. +# + echo "╔══════════════════════════════════════╗" echo "║ TSMusicBot Installer ║" echo "╚══════════════════════════════════════╝" @@ -11,6 +21,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" INSTALL_DIR="/opt/tsmusicbot" SERVICE_NAME="tsmusicbot" +# Node LTS line to install when a supported Node is missing. Keep in sync with +# package.json "engines" and the floor check in setup.sh (#152). +NODE_LTS_MAJOR=22 # Verify we're in a valid project directory if [ ! -f "$PROJECT_DIR/package.json" ]; then @@ -28,42 +41,66 @@ else exit 1 fi -echo "[1/6] Installing system dependencies..." +# Supported: 22.12+ or 24+ (odd majors are excluded by better-sqlite3 and vitest). +node_supported() { + command -v node &> /dev/null && + node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)' +} + +echo "[1/5] Installing system dependencies..." case $OS in ubuntu|debian) sudo apt-get update -qq - sudo apt-get install -y -qq curl build-essential python3 + sudo apt-get install -y -qq curl ca-certificates build-essential python3 ffmpeg ;; - centos|rhel|fedora) + centos|rhel|fedora|rocky|almalinux) sudo yum install -y curl gcc gcc-c++ make python3 ;; arch|manjaro) - sudo pacman -S --noconfirm curl base-devel python + sudo pacman -S --noconfirm --needed curl base-devel python ffmpeg ;; *) - echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually." + echo "Unsupported OS: $OS. Please install Node.js ${NODE_LTS_MAJOR}.12+ and build tools manually." ;; esac -echo "[2/6] Installing Node.js 20 LTS..." -if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null -fi -echo "Node.js $(node -v) installed" - -echo "[3/6] Installing dependencies..." -cd "$PROJECT_DIR" -npm install -if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then - (cd "$PROJECT_DIR/web" && npm install) +echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..." +if node_supported; then + echo "Node.js $(node -v) already installed" +else + case $OS in + ubuntu|debian) + curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash - + sudo apt-get install -y -qq nodejs + ;; + centos|rhel|fedora|rocky|almalinux) + curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash - + sudo yum install -y nodejs + ;; + arch|manjaro) + sudo pacman -S --noconfirm --needed nodejs npm + ;; + esac + if ! node_supported; then + echo "Error: Node.js 22.12+ (or 24+) is required, found: $(node -v 2>/dev/null || echo none)." + echo "Install it from https://nodejs.org/ (or https://nodejs.cn/) and re-run this script." + exit 1 + fi + echo "Node.js $(node -v) installed" fi -echo "[4/6] Building project..." -npm run build +echo "[3/5] Installing dependencies and building (scripts/setup.sh)..." +bash "$SCRIPT_DIR/setup.sh" -echo "[5/6] Copying to $INSTALL_DIR..." +echo "[4/5] Copying to $INSTALL_DIR..." +# Stop a running copy before replacing its files (re-install / upgrade). +if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + sudo systemctl stop "$SERVICE_NAME" +fi sudo mkdir -p "$INSTALL_DIR" +# Replace build output wholesale so files removed upstream don't linger. +# data/ (config, database, cookies) is never touched. +sudo rm -rf "$INSTALL_DIR/dist" "$INSTALL_DIR/node_modules" "$INSTALL_DIR/web/dist" sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/" sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/" sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/" @@ -72,13 +109,18 @@ if [ -d "$PROJECT_DIR/web/dist" ]; then sudo mkdir -p "$INSTALL_DIR/web" sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/" fi +# yt-dlp is looked up in bin/ next to dist/ before falling back to PATH +if [ -d "$PROJECT_DIR/bin" ]; then + sudo cp -r "$PROJECT_DIR/bin" "$INSTALL_DIR/" +fi # Copy scripts for future use sudo mkdir -p "$INSTALL_DIR/scripts" sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true # Create data directory sudo mkdir -p "$INSTALL_DIR/data" -echo "[6/6] Creating systemd service..." +echo "[5/5] Creating systemd service..." +NODE_BIN="$(command -v node)" sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null </dev/null; then - echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org" + echo "[ERROR] Node.js not found. Please install Node.js 22.12+ LTS from https://nodejs.org" echo " or https://nodejs.cn/ (China mirror)." exit 1 fi diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 137c907..69abf14 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -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 }, + ) => Promise; + + 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 = { + 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"); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 58e74ca..fff584f 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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"; @@ -456,11 +462,16 @@ export class BotInstance extends EventEmitter { this.voiceDucking.removeSpeaker(event.id); void this.refreshOccupancy(); }); - this.tsClient.on("clientMoved", (event: { id: number }) => { + this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => { if (event.id === this.tsClient.getClientId()) { // Moving the bot invalidates every activity deadline from its old // channel even if no individual leave events arrive. this.voiceDucking.reset(false); + // Carry the now-playing channel description over to the new + // channel instead of leaving it stale in the old one (#159). + this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => { + this.logger.warn({ err }, "Channel description move update failed"); + }); } else { this.voiceDucking.removeSpeaker(event.id); } @@ -968,6 +979,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 +1153,12 @@ export class BotInstance extends EventEmitter { return { error: `No recent search. Use ${p}search 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 +1175,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 { @@ -1495,8 +1524,21 @@ export class BotInstance extends EventEmitter { } private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise { - if (!cmd.args) return "Usage: !playlist "; - const provider = this.getProvider(cmd.flags); + if (!cmd.args) return "Usage: !playlist "; + + // 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 +1546,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 { diff --git a/src/bot/profile.test.ts b/src/bot/profile.test.ts index 59ebf93..2575e74 100644 --- a/src/bot/profile.test.ts +++ b/src/bot/profile.test.ts @@ -202,3 +202,65 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => { expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true); }); }); + +describe("BotProfileManager channel description follows the bot (#159)", () => { + const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true }; + let ts: ReturnType & { cid: bigint }; + let channelEdits: () => string[]; + + beforeEach(() => { + ts = makeMockTs() as any; + ts.cid = 5n; + (ts as any).getChannelId = () => ts.cid; + channelEdits = () => + (ts.sendCommandNoWait as any).mock.calls + .map((c: any[]) => c[0] as string) + .filter((cmd: string) => cmd.startsWith("channeledit")); + }); + + it("clears the old channel and fills the new one when moved while playing", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + await pm.onSongChange(fakeSong); + expect(channelEdits()).toEqual([ + expect.stringMatching(/^channeledit cid=5 channel_description=\S+/), + ]); + + ts.cid = 9n; + await pm.onChannelMoved(9n); + + const edits = channelEdits(); + expect(edits[1]).toBe("channeledit cid=5 channel_description="); + expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/); + }); + + it("stopping after a move clears the channel the bot is in now, not the old one", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + await pm.onSongChange(fakeSong); + ts.cid = 9n; + await pm.onChannelMoved(9n); + await pm.onSongChange(null); + expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description="); + }); + + it("a move while idle touches no channel description", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + ts.cid = 9n; + await pm.onChannelMoved(9n); + expect(channelEdits()).toEqual([]); + }); + + it("a move is ignored when the channel description feature is off", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot"); + await pm.onSongChange(fakeSong); + ts.cid = 9n; + await pm.onChannelMoved(9n); + expect(channelEdits()).toEqual([]); + }); + + it("an event for the channel the description is already in is a no-op", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + await pm.onSongChange(fakeSong); + await pm.onChannelMoved(5n); + expect(channelEdits()).toHaveLength(1); + }); +}); diff --git a/src/bot/profile.ts b/src/bot/profile.ts index 3c04ef7..3ce0d20 100644 --- a/src/bot/profile.ts +++ b/src/bot/profile.ts @@ -32,6 +32,13 @@ export class BotProfileManager { * pushed immediately (idle) or wait for the next stop event (playing). */ private currentSong: QueuedSong | null = null; + /** + * Channel whose description currently holds our now-playing text, or null + * if we have not written one. Remembered so that when the bot is moved we + * can still clean up the channel it was taken out of (#159) — by then + * getChannelId() already reports the new channel. + */ + private channelDescCid: bigint | null = null; /** Per-feature permission-denied flags. Reset on reconnect. */ private permDenied = { @@ -134,6 +141,8 @@ export class BotProfileManager { onConnect(): void { this.generation++; this.currentSong = null; + // Channel ids are per-server; never carry one across a (re)connect. + this.channelDescCid = null; this.permDenied = { avatar: false, description: false, @@ -150,6 +159,31 @@ export class BotProfileManager { } } + /** + * Called when the bot itself has been moved to another channel (#159). + * Clears the now-playing text from the channel it left and, if a song is + * playing, writes it to the channel it is in now. + */ + async onChannelMoved(newChannelId: bigint): Promise { + if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return; + const oldChannelId = this.channelDescCid; + if (oldChannelId === newChannelId) return; + try { + if (oldChannelId !== null) { + await this.tsClient.sendCommandNoWait( + `channeledit cid=${oldChannelId} channel_description=`, + ); + this.channelDescCid = null; + } + } catch (err) { + this.handleFeatureError("channelDesc", err); + return; + } + if (this.currentSong) { + await this.updateChannelDescription(this.currentSong, newChannelId); + } + } + getConfig(): ProfileConfig { return { ...this.config }; } @@ -400,18 +434,25 @@ export class BotProfileManager { return str.slice(0, end) + ellipsis; } - private async updateChannelDescription(song: QueuedSong | null): Promise { + private async updateChannelDescription( + song: QueuedSong | null, + targetChannelId?: bigint, + ): Promise { if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return; try { - const channelId = this.tsClient.getChannelId(); - if (channelId === 0n) return; // unknown channel - + const channelId = targetChannelId ?? this.tsClient.getChannelId(); if (!song) { + // Prefer the channel we actually wrote to, in case a move event + // was missed and the bot is somewhere else now. + const target = this.channelDescCid ?? channelId; + if (target === 0n) return; // unknown channel await this.tsClient.sendCommandNoWait( - `channeledit cid=${channelId} channel_description=`, + `channeledit cid=${target} channel_description=`, ); + this.channelDescCid = null; return; } + if (channelId === 0n) return; // unknown channel const lines = [ `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放: @@ -422,6 +463,7 @@ export class BotProfileManager { await this.tsClient.sendCommandNoWait( `channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`, ); + this.channelDescCid = channelId; } catch (err) { this.handleFeatureError("channelDesc", err); } diff --git a/src/bot/song-ref.test.ts b/src/bot/song-ref.test.ts index 3d41421..d6e23f9 100644 --- a/src/bot/song-ref.test.ts +++ b/src/bot/song-ref.test.ts @@ -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(); + }); +}); diff --git a/src/bot/song-ref.ts b/src/bot/song-ref.ts index a13092f..6578067 100644 --- a/src/bot/song-ref.ts +++ b/src/bot/song-ref.ts @@ -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 { + try { + const { status, location } = await get(url); + if (status < 300 || status >= 400 || !location) return null; + return new URL(location, url).toString(); + } catch { + return null; + } +} diff --git a/src/data/database.test.ts b/src/data/database.test.ts index b03ff1e..64da9dc 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -345,3 +345,36 @@ describe("guest principal migration", () => { rmSync(dir, { recursive: true, force: true }); }); }); + +describe("user music cookies (#164)", () => { + let botDb: BotDatabase; + const addUser = (id: string) => + botDb.db + .prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)") + .run(id, id, "x", 0, 0, "member"); + + beforeEach(() => { + botDb = createDatabase(":memory:"); + addUser("u1"); + addUser("u2"); + }); + afterEach(() => botDb.close()); + + it("stores, overwrites and deletes a cookie per user and platform", () => { + expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull(); + botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a"); + botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=b"); + expect(botDb.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=b"); + expect(botDb.getUserMusicCookie("u2", "netease")).toBeNull(); + expect(botDb.getUserMusicCookie("u1", "qq")).toBeNull(); + expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(true); + expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(false); + expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull(); + }); + + it("drops a user's cookies when the user is deleted", () => { + botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a"); + botDb.db.prepare("DELETE FROM users WHERE id = ?").run("u1"); + expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull(); + }); +}); diff --git a/src/data/database.ts b/src/data/database.ts index f6b9071..950850e 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -144,6 +144,10 @@ export interface BotDatabase { removeFavorite(userId: string, playlistId: string, platform: string): boolean; getFavorites(userId: string): FavoritePlaylist[]; isFavorited(userId: string, playlistId: string, platform: string): boolean; + // Per-user music account cookies (#164). + getUserMusicCookie(userId: string, platform: string): string | null; + setUserMusicCookie(userId: string, platform: string, cookie: string): void; + deleteUserMusicCookie(userId: string, platform: string): boolean; // Saved queues (Feature 1) — upsert by (ownerId, name), capped. saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[]; @@ -340,6 +344,17 @@ function initTables(db: Database.Database): void { fmPlatform TEXT NOT NULL DEFAULT '', updatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); + + -- A web user's own music-platform login (#164), used for their personal + -- FM instead of the bot's shared account. Secret: never sent to clients. + CREATE TABLE IF NOT EXISTS user_music_cookies ( + userId TEXT NOT NULL, + platform TEXT NOT NULL, + cookie TEXT NOT NULL, + updatedAt TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (userId, platform), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); `); } @@ -465,6 +480,17 @@ export function createDatabase(dbPath: string): BotDatabase { SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ? `); + const selectUserMusicCookie = db.prepare( + `SELECT cookie FROM user_music_cookies WHERE userId = ? AND platform = ?`, + ); + const upsertUserMusicCookie = db.prepare(` + INSERT INTO user_music_cookies (userId, platform, cookie) VALUES (?, ?, ?) + ON CONFLICT(userId, platform) DO UPDATE SET cookie = excluded.cookie, updatedAt = datetime('now') + `); + const deleteUserMusicCookieStmt = db.prepare( + `DELETE FROM user_music_cookies WHERE userId = ? AND platform = ?`, + ); + // A corrupt/hand-edited songs blob must never throw into a route or the // restore path — degrade to an empty list instead. const parseSongs = (raw: string): StoredSong[] => { @@ -646,6 +672,19 @@ export function createDatabase(dbPath: string): BotDatabase { return row !== undefined; }, + getUserMusicCookie(userId, platform) { + const row = selectUserMusicCookie.get(userId, platform) as { cookie: string } | undefined; + return row?.cookie ?? null; + }, + + setUserMusicCookie(userId, platform, cookie) { + upsertUserMusicCookie.run(userId, platform, cookie); + }, + + deleteUserMusicCookie(userId, platform) { + return deleteUserMusicCookieStmt.run(userId, platform).changes > 0; + }, + saveQueue(ownerId, name, songs) { if (songs.length > MAX_QUEUE_SONGS) { throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`); diff --git a/src/music/bilibili.test.ts b/src/music/bilibili.test.ts index da3c4da..cb43cc5 100644 --- a/src/music/bilibili.test.ts +++ b/src/music/bilibili.test.ts @@ -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 + }); +}); diff --git a/src/music/bilibili.ts b/src/music/bilibili.ts index cde63c2..fa0e080 100644 --- a/src/music/bilibili.ts +++ b/src/music/bilibili.ts @@ -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 { + 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 { + 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 { - const cached = this.cidCache.get(bvid); + private async getCid(bvid: string, page = 1): Promise { + 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 { - 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, diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts index aff9f89..800689f 100644 --- a/src/music/netease.test.ts +++ b/src/music/netease.test.ts @@ -139,3 +139,48 @@ describe("NeteaseProvider.search pagination", () => { expect(callByType(get, 10).offset).toBe(0); }); }); + +describe("NeteaseProvider per-user login (#164)", () => { + function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) { + const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) })); + (p as any).api = { get, defaults: { baseURL: "http://127.0.0.1:3001" } }; + return get; + } + + it("pollQrLogin returns the cookie without touching the shared account", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + p.setCookie("MUSIC_U=shared"); + withGet(p, () => ({ code: 803, cookie: "MUSIC_U=personal" })); + expect(await p.pollQrLogin("k")).toEqual({ status: "confirmed", cookie: "MUSIC_U=personal" }); + expect(p.getCookie()).toBe("MUSIC_U=shared"); + }); + + it("pollQrLogin maps the waiting / scanned / expired codes", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + let code = 801; + withGet(p, () => ({ code })); + expect(await p.pollQrLogin("k")).toEqual({ status: "waiting" }); + code = 802; + expect(await p.pollQrLogin("k")).toEqual({ status: "scanned" }); + code = 800; + expect(await p.pollQrLogin("k")).toEqual({ status: "expired" }); + }); + + it("checkQrCodeStatus still stores the cookie on the shared provider (admin login)", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + withGet(p, () => ({ code: 803, cookie: "MUSIC_U=admin" })); + expect(await p.checkQrCodeStatus("k")).toBe("confirmed"); + expect(p.getCookie()).toBe("MUSIC_U=admin"); + }); + + it("withCookie gives a view that fetches FM with the other account's cookie", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + p.setCookie("MUSIC_U=shared"); + const personal = p.withCookie("MUSIC_U=personal"); + const get = withGet(personal, () => ({ data: [] })); + await personal.getPersonalFm(); + expect(get.mock.calls[0][1].params.cookie).toBe("MUSIC_U=personal"); + expect(p.getCookie()).toBe("MUSIC_U=shared"); + expect(personal.platform).toBe("netease"); + }); +}); diff --git a/src/music/netease.ts b/src/music/netease.ts index 2a9acc8..05bcd14 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -111,8 +111,10 @@ export class NeteaseProvider implements MusicProvider { private api: AxiosInstance; private cookie = ""; private quality = "exhigh"; + private readonly baseUrl: string; constructor(baseUrl: string) { + this.baseUrl = baseUrl; this.api = axios.create({ baseURL: baseUrl, timeout: 10000, @@ -243,25 +245,47 @@ export class NeteaseProvider implements MusicProvider { async checkQrCodeStatus( key: string ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { + const { status, cookie } = await this.pollQrLogin(key); + if (cookie) this.cookie = cookie; + return status; + } + + /** + * Poll a QR login and hand back the resulting cookie WITHOUT storing it on + * this provider — for a web user linking their own account (#164), which + * must never replace the bot's shared login. + */ + async pollQrLogin( + key: string + ): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }> { const res = await this.api.get("/login/qr/check", { params: { key, timestamp: Date.now() }, }); - const code = res.data?.code; - switch (code) { + switch (res.data?.code) { case 801: - return "waiting"; + return { status: "waiting" }; case 802: - return "scanned"; + return { status: "scanned" }; case 803: - if (res.data?.cookie) { - this.cookie = res.data.cookie; - } - return "confirmed"; + return res.data?.cookie + ? { status: "confirmed", cookie: res.data.cookie } + : { status: "confirmed" }; default: - return "expired"; + return { status: "expired" }; } } + /** + * A provider for the same API server logged in as another account (#164): + * a web user's personal FM uses their own taste instead of the shared login. + */ + withCookie(cookie: string): NeteaseProvider { + const view = new NeteaseProvider(this.baseUrl); + view.setQuality(this.quality); + view.setCookie(cookie); + return view; + } + async sendSmsCode(phone: string): Promise { const res = await this.api.get("/captcha/sent", { params: { phone }, diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index 42f10ef..b066b8b 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -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); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 3c5865e..f54876a 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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. diff --git a/src/web/api/personal-music.test.ts b/src/web/api/personal-music.test.ts new file mode 100644 index 0000000..8faf1ad --- /dev/null +++ b/src/web/api/personal-music.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createDatabase } from "../../data/database.js"; +import { createPersonalMusicRouter } from "./personal-music.js"; +import { createPlayerRouter } from "./player.js"; + +function mount() { + const db = createDatabase(":memory:"); + db.db + .prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)") + .run("u1", "alice", "x", 0, 0, "member"); + const personalView = { + getAuthStatus: vi.fn(async () => ({ loggedIn: true, nickname: "Alice163" })), + }; + const provider: any = { + platform: "netease", + getQrCode: vi.fn(async () => ({ qrUrl: "u", qrImg: "data:img", key: "k1" })), + pollQrLogin: vi.fn(async () => ({ status: "waiting" })), + withCookie: vi.fn(() => personalView), + setCookie: vi.fn(), + }; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).user = { id: "u1", username: "alice", role: "member" }; + next(); + }); + app.use("/api/me/music", createPersonalMusicRouter(db, provider, pino({ level: "silent" }))); + return { app, db, provider, personalView }; +} + +describe("personal music account router (#164)", () => { + it("reports not linked until the user logs in", async () => { + const { app } = mount(); + const res = await request(app).get("/api/me/music/netease/status"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ linked: false, loggedIn: false }); + }); + + it("creates a QR code", async () => { + const { app } = mount(); + const res = await request(app).post("/api/me/music/netease/qrcode"); + expect(res.body).toEqual({ qrUrl: "u", qrImg: "data:img", key: "k1" }); + }); + + it("stores the cookie for this user on confirm, never on the shared provider, and never returns it", async () => { + const { app, db, provider } = mount(); + provider.pollQrLogin.mockResolvedValue({ status: "confirmed", cookie: "MUSIC_U=alice" }); + const res = await request(app).get("/api/me/music/netease/qrcode/status").query({ key: "k1" }); + expect(res.body).toEqual({ status: "confirmed" }); + expect(JSON.stringify(res.body)).not.toContain("MUSIC_U"); + expect(db.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=alice"); + expect(provider.setCookie).not.toHaveBeenCalled(); + }); + + it("requires a key to poll", async () => { + const { app } = mount(); + expect((await request(app).get("/api/me/music/netease/qrcode/status")).status).toBe(400); + }); + + it("reports the linked account's nickname via a view on the user's cookie", async () => { + const { app, db, provider } = mount(); + db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice"); + const res = await request(app).get("/api/me/music/netease/status"); + expect(res.body).toEqual({ linked: true, loggedIn: true, nickname: "Alice163" }); + expect(provider.withCookie).toHaveBeenCalledWith("MUSIC_U=alice"); + }); + + it("unlinks", async () => { + const { app, db } = mount(); + db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice"); + expect((await request(app).delete("/api/me/music/netease")).status).toBe(200); + expect(db.getUserMusicCookie("u1", "netease")).toBeNull(); + }); +}); + +describe("web FM uses the caller's linked NetEase account (#164)", () => { + async function startFm(opts: { linked: boolean; role?: string; platform?: string }) { + const db = createDatabase(":memory:"); + db.db + .prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)") + .run("u1", "alice", "x", 0, 0, "member"); + if (opts.linked) db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice"); + const personal = { platform: "netease", personal: true }; + const shared: any = { platform: "netease", pollQrLogin: vi.fn(), withCookie: vi.fn(() => personal) }; + const qq: any = { platform: "qq" }; + const bot = { + id: "b1", + getProviderFor: (p: string) => (p === "qq" ? qq : shared), + startFm: vi.fn(async (_provider: unknown) => "Personal FM started"), + }; + const botManager: any = { getBot: () => bot }; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).user = { + id: "u1", username: "alice", role: opts.role ?? "member", + capabilities: new Set(["player.control"]), bots: "all", guest: { playMode: true }, + }; + next(); + }); + app.use("/api/player", createPlayerRouter(botManager, pino({ level: "silent" }), db)); + const res = await request(app).post("/api/player/b1/fm").send({ platform: opts.platform ?? "netease" }); + return { res, bot, shared, personal, qq }; + } + + it("starts FM on the user's own account when linked", async () => { + const { res, bot, shared, personal } = await startFm({ linked: true }); + expect(res.status).toBe(200); + expect(shared.withCookie).toHaveBeenCalledWith("MUSIC_U=alice"); + expect(bot.startFm.mock.calls[0][0]).toBe(personal); + }); + + it("falls back to the shared account when the user has not linked one", async () => { + const { bot, shared } = await startFm({ linked: false }); + expect(bot.startFm.mock.calls[0][0]).toBe(shared); + }); + + it("leaves other platforms alone", async () => { + const { bot, qq } = await startFm({ linked: true, platform: "qq" }); + expect(bot.startFm.mock.calls[0][0]).toBe(qq); + }); +}); diff --git a/src/web/api/personal-music.ts b/src/web/api/personal-music.ts new file mode 100644 index 0000000..dfc3007 --- /dev/null +++ b/src/web/api/personal-music.ts @@ -0,0 +1,95 @@ +import { Router } from "express"; +import type { BotDatabase } from "../../data/database.js"; +import type { MusicProvider, QrCodeResult } from "../../music/provider.js"; +import type { Logger } from "../../logger.js"; + +/** + * A provider that can log a web user into their OWN account without touching + * the bot's shared login, and hand out a view bound to that account (#164). + */ +export interface PersonalLoginProvider { + getQrCode(): Promise; + pollQrLogin(key: string): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }>; + withCookie(cookie: string): MusicProvider; +} + +export function supportsPersonalLogin( + provider: MusicProvider | undefined, +): provider is MusicProvider & PersonalLoginProvider { + const p = provider as Partial | undefined; + return typeof p?.pollQrLogin === "function" && typeof p.withCookie === "function"; +} + +/** + * The caller's own NetEase account, used for their personal FM instead of the + * bot's shared login (#164). Every route acts on req.user only; the cookie is + * stored server-side and never sent back to the browser. + */ +export function createPersonalMusicRouter( + database: BotDatabase, + neteaseProvider: MusicProvider, + logger: Logger, +): Router { + const router = Router(); + const platform = "netease"; + + router.use((_req, res, next) => { + if (!supportsPersonalLogin(neteaseProvider)) { + res.status(501).json({ error: "Personal login not supported" }); + return; + } + next(); + }); + const provider = neteaseProvider as MusicProvider & PersonalLoginProvider; + + router.get("/netease/status", async (req, res) => { + const cookie = database.getUserMusicCookie(req.user!.id, platform); + if (!cookie) { + res.json({ linked: false, loggedIn: false }); + return; + } + try { + const status = await provider.withCookie(cookie).getAuthStatus(); + res.json({ linked: true, ...status }); + } catch (err) { + logger.warn({ err }, "Personal NetEase status check failed"); + res.json({ linked: true, loggedIn: false }); + } + }); + + router.post("/netease/qrcode", async (_req, res) => { + try { + res.json(await provider.getQrCode()); + } catch (err) { + logger.error({ err }, "Personal NetEase QR generation failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/netease/qrcode/status", async (req, res) => { + const key = req.query.key; + if (typeof key !== "string" || !key) { + res.status(400).json({ error: "key is required" }); + return; + } + try { + const { status, cookie } = await provider.pollQrLogin(key); + if (status === "confirmed" && cookie) { + database.setUserMusicCookie(req.user!.id, platform, cookie); + logger.info({ userId: req.user!.id, platform }, "Personal music account linked"); + } + res.json({ status }); + } catch (err) { + logger.error({ err }, "Personal NetEase QR status check failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.delete("/netease", (req, res) => { + database.deleteUserMusicCookie(req.user!.id, platform); + logger.info({ userId: req.user!.id, platform }, "Personal music account unlinked"); + res.json({ ok: true }); + }); + + return router; +} diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 8cdcac7..ab67326 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -6,6 +6,7 @@ import type { Logger } from "../../logger.js"; import { parseCommand } from "../../bot/commands.js"; import { requireBotAccess } from "../middleware/requirePermission.js"; import { authorize } from "../middleware/authorize.js"; +import { supportsPersonalLogin } from "./personal-music.js"; export function createPlayerRouter( botManager: BotManager, @@ -124,11 +125,19 @@ export function createPlayerRouter( rejectDisabledLocalAudio(res); return; } - const provider = bot.getProviderFor( + let provider = bot.getProviderFor( platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin" ? platform : "netease" ); + // A signed-in user who linked their own NetEase account gets FM from + // THEIR taste, not the bot's shared login (#164). Songs still resolve + // through the shared provider when played. + const user = (req as any).user; + if (provider.platform === "netease" && user && user.role !== "guest" && database) { + const cookie = database.getUserMusicCookie(user.id, "netease"); + if (cookie && supportsPersonalLogin(provider)) provider = provider.withCookie(cookie); + } const message = await bot.startFm(provider, requesterName(req)); res.json({ ok: diff --git a/src/web/server.ts b/src/web/server.ts index b26923e..f46df14 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js"; import { createAuditStore } from "../data/audit.js"; import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; +import { createPersonalMusicRouter } from "./api/personal-music.js"; import { createSavedQueuesRouter } from "./api/saved-queues.js"; import { createSpotifyRouter } from "./api/spotify.js"; import { createApiKeysRouter } from "./api/api-keys.js"; @@ -206,6 +207,13 @@ export function createWebServer(options: WebServerOptions): WebServer { ); } app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); + // The caller's own NetEase login for their personal FM (#164). Guests share + // one anonymous identity, so they cannot link an account. + app.use( + "/api/me/music", + requireNotGuest, + createPersonalMusicRouter(options.database, options.neteaseProvider, logger), + ); // Saved queues (Feature 1, #119). Members + admins only (requireNotGuest); // the router itself 403s every route unless savedQueuesEnabled is on. app.use( diff --git a/web/src/App.vue b/web/src/App.vue index 51ae196..55e5675 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -7,6 +7,7 @@ +
@@ -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(); diff --git a/web/src/components/BilibiliPartModal.vue b/web/src/components/BilibiliPartModal.vue new file mode 100644 index 0000000..10aca6b --- /dev/null +++ b/web/src/components/BilibiliPartModal.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/web/src/components/PersonalNeteaseAccount.vue b/web/src/components/PersonalNeteaseAccount.vue new file mode 100644 index 0000000..9fb3eec --- /dev/null +++ b/web/src/components/PersonalNeteaseAccount.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index dfc6ac4..696033a 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -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 { + 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 }); }, diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 23c95a0..1fe388b 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -48,6 +48,7 @@

{{ ownPwError }}

{{ ownPwSuccess }}

+ @@ -1215,6 +1216,7 @@ import { Icon } from '@iconify/vue'; import axios from 'axios'; import AvatarUpload from '../components/AvatarUpload.vue'; import CustomAvatarRow from '../components/CustomAvatarRow.vue'; +import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue'; import QRCode from 'qrcode'; import { usePlayerStore } from '../stores/player.js'; import { useSession } from '../composables/useSession.js';