mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3422d45eeb | ||
|
|
f7626f40b3 | ||
|
|
de2c956c31 | ||
|
|
3802c90d2d | ||
|
|
839f777a75 | ||
|
|
dd6affca6d | ||
|
|
bea2f92508 | ||
|
|
f19f56a666 | ||
|
|
140020f63a | ||
|
|
6e10764d28 | ||
|
|
9bfe831022 | ||
|
|
bbdd4cbc78 | ||
|
|
c57cd35f09 | ||
|
|
1a1f365cf1 | ||
|
|
d1544bab42 | ||
|
|
e0d17cf404 | ||
|
|
b2de607391 | ||
|
|
355793b7e6 | ||
|
|
593b42830c | ||
|
|
463a8e2f8a | ||
|
|
88ac7d2a68 | ||
|
|
53d28de17e | ||
|
|
ca07ebc3b7 | ||
|
|
bf1fb1fd88 | ||
|
|
846fb2c28c | ||
|
|
34655e5f50 | ||
|
|
491bc53dec | ||
|
|
8f5bb26b3a | ||
|
|
4ba4b013b0 | ||
|
|
484202e90d | ||
|
|
9f0ac74fbc | ||
|
|
51c954993a | ||
|
|
a861b41809 | ||
|
|
e9b3ba0075 | ||
|
|
0401534b88 | ||
|
|
f720da49d6 |
No files matched your search
@@ -383,11 +383,11 @@ teamspeak-music-bot/
|
|||||||
│ └── docker/ # Docker 部署文件
|
│ └── docker/ # Docker 部署文件
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── docker-compose.yml
|
│ └── docker-compose.yml
|
||||||
├── data/ # 运行时数据(自动创建,不上传)
|
└── data/ # 运行时数据(自动创建,不上传)
|
||||||
│ ├── tsmusicbot.db # SQLite 数据库
|
├── config.json # 配置文件(首次运行自动生成,可手动编辑)
|
||||||
│ ├── cookies/ # 登录 Cookie
|
├── tsmusicbot.db # SQLite 数据库
|
||||||
│ └── logs/ # 日志文件
|
├── cookies/ # 登录 Cookie
|
||||||
└── config.json # 配置文件(首次运行自动生成,不上传)
|
└── logs/ # 日志文件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Auto-pause on Empty Channel — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
|
||||||
|
|
||||||
|
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
|
||||||
|
|
||||||
|
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Pure occupancy-decision function
|
||||||
|
|
||||||
|
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
describe("decideOccupancyAction", () => {
|
||||||
|
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
|
||||||
|
it("pauses when empty while playing and enabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||||
|
});
|
||||||
|
it("does not pause when the feature is disabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when idle (nothing playing)", () => {
|
||||||
|
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when already paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resumes when re-populated and we auto-paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||||
|
});
|
||||||
|
it("does NOT resume a user-paused track on re-population", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does nothing when re-populated and already playing", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||||
|
export type OccupancyAction = "pause" | "resume" | "none";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what auto-pause should do given the channel occupancy.
|
||||||
|
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||||
|
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||||
|
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||||
|
*/
|
||||||
|
export function decideOccupancyAction(
|
||||||
|
playerState: PlayerStateName,
|
||||||
|
autoPaused: boolean,
|
||||||
|
enabled: boolean,
|
||||||
|
userCount: number,
|
||||||
|
): OccupancyAction {
|
||||||
|
const empty = userCount <= 0;
|
||||||
|
if (empty) {
|
||||||
|
if (enabled && playerState === "playing") return "pause";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
if (autoPaused && playerState === "paused") return "resume";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
|
||||||
|
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
|
||||||
|
|
||||||
|
**Files:** Modify `src/bot/instance.ts`.
|
||||||
|
|
||||||
|
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
private handleOccupancy(userCount: number): void {
|
||||||
|
// idle-disconnect (unchanged behavior)
|
||||||
|
if (userCount <= 0) this._scheduleIdleCheck();
|
||||||
|
else this._cancelIdleTimer();
|
||||||
|
|
||||||
|
// auto-pause
|
||||||
|
const action = decideOccupancyAction(
|
||||||
|
this.player.getState() as "idle" | "playing" | "paused",
|
||||||
|
this.autoPaused,
|
||||||
|
this.config.autoPauseOnEmpty,
|
||||||
|
userCount,
|
||||||
|
);
|
||||||
|
if (action === "pause") {
|
||||||
|
this.player.pause();
|
||||||
|
this.autoPaused = true;
|
||||||
|
this.emit("stateChange");
|
||||||
|
} else if (action === "resume") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
|
||||||
|
|
||||||
|
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
updateAutoPause(enabled: boolean): void {
|
||||||
|
this.config.autoPauseOnEmpty = enabled;
|
||||||
|
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
|
||||||
|
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
|
||||||
|
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Re-emit TS member events for instant reaction
|
||||||
|
|
||||||
|
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
|
||||||
|
|
||||||
|
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
|
||||||
|
|
||||||
|
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
|
||||||
|
|
||||||
|
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
|
||||||
|
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: API wiring for the toggle
|
||||||
|
|
||||||
|
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
|
||||||
|
|
||||||
|
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
|
||||||
|
|
||||||
|
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
|
||||||
|
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend toggle in Settings
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
|
||||||
|
|
||||||
|
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
- [ ] `npx tsc --noEmit` → 0
|
||||||
|
- [ ] `npx vitest run src/` → all pass
|
||||||
|
- [ ] `cd web && npx vue-tsc --noEmit` → 0
|
||||||
|
- [ ] `npm run build` → succeeds
|
||||||
|
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
|
||||||
|
|
||||||
|
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
|
||||||
|
|
||||||
|
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Store scope state + pure resolve helper (with test)
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
|
||||||
|
|
||||||
|
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
|
||||||
|
* effective scope. Returns the id if it exists, else null (graceful clear:
|
||||||
|
* a stale/forbidden id never locks the UI). */
|
||||||
|
export function resolveScopedBot(
|
||||||
|
requestedId: string | null | undefined,
|
||||||
|
knownBotIds: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
if (!requestedId) return null;
|
||||||
|
return knownBotIds.includes(requestedId) ? requestedId : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`web/src/stores/scope.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveScopedBot } from "./scope.js";
|
||||||
|
|
||||||
|
describe("resolveScopedBot", () => {
|
||||||
|
it("returns null when no id requested", () => {
|
||||||
|
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
|
||||||
|
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
|
||||||
|
expect(resolveScopedBot("", ["a"])).toBeNull();
|
||||||
|
});
|
||||||
|
it("returns the id when it exists in the bot list", () => {
|
||||||
|
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
|
||||||
|
});
|
||||||
|
it("clears (null) when the requested id is not a known bot", () => {
|
||||||
|
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
|
||||||
|
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement the helper** (code above).
|
||||||
|
|
||||||
|
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
|
||||||
|
- state: `scopedBotId: null as string | null`.
|
||||||
|
- getter: `isScoped: (state) => state.scopedBotId !== null`.
|
||||||
|
- actions:
|
||||||
|
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
|
||||||
|
- `clearScope()` → `this.scopedBotId = null;`.
|
||||||
|
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
|
||||||
|
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
|
||||||
|
|
||||||
|
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/router/index.ts`.
|
||||||
|
|
||||||
|
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
|
||||||
|
if (qBot) {
|
||||||
|
// entering/with a scope in the URL — store will validate against bots later
|
||||||
|
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (store.scopedBotId) {
|
||||||
|
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
|
||||||
|
if (to.query.bot !== store.scopedBotId) {
|
||||||
|
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
|
||||||
|
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
|
||||||
|
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: BotRedirect seeds the URL scope
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/views/BotRedirect.vue`.
|
||||||
|
|
||||||
|
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
|
||||||
|
- keep the fetchBots + existence check,
|
||||||
|
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
|
||||||
|
- if not found: unchanged (show "机器人不存在或未加载").
|
||||||
|
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Navbar lock UI + apply-scope-on-load
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
|
||||||
|
|
||||||
|
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
|
||||||
|
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
|
||||||
|
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
|
||||||
|
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
|
||||||
|
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
|
||||||
|
When not scoped: behavior unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
|
||||||
|
|
||||||
|
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
|
||||||
|
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
|
||||||
|
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
|
||||||
|
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
|
||||||
|
- [ ] `npm run build` → succeeds
|
||||||
|
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
|
||||||
|
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
|
||||||
|
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Auto-pause on empty channel — design
|
||||||
|
|
||||||
|
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
|
||||||
|
**Date:** 2026-05-30
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
|
||||||
|
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
|
||||||
|
and resume when someone returns.
|
||||||
|
|
||||||
|
## Decisions (from brainstorm)
|
||||||
|
|
||||||
|
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
|
||||||
|
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
|
||||||
|
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
|
||||||
|
enter/leave/move events; keep the existing 30s idle poll as a fallback.
|
||||||
|
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
|
||||||
|
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
|
||||||
|
emptiness signal but act independently (pause immediately; disconnect after N minutes).
|
||||||
|
|
||||||
|
## Current state (verified)
|
||||||
|
|
||||||
|
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
|
||||||
|
bot*; callers compute "others" as `length - 1`. No persistent roster.
|
||||||
|
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
|
||||||
|
*logs* `clientEnter` and does not re-emit leave/moved.
|
||||||
|
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
|
||||||
|
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
|
||||||
|
idle-disconnect after `idleTimeoutMinutes`.
|
||||||
|
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
|
||||||
|
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
|
||||||
|
auto-vs-user-pause distinction today.
|
||||||
|
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Occupancy signal (shared)
|
||||||
|
Extract the idle poller's count into one method on `BotInstance`:
|
||||||
|
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
|
||||||
|
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
|
||||||
|
auto-pause logic below. It is called by:
|
||||||
|
1. the existing 30s poll (fallback), and
|
||||||
|
2. new TS event handlers.
|
||||||
|
|
||||||
|
### Event subscription
|
||||||
|
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
|
||||||
|
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
|
||||||
|
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
|
||||||
|
the poll remains as a safety net.
|
||||||
|
|
||||||
|
### Auto-pause logic (inside `checkChannelOccupancy`)
|
||||||
|
Add a private `autoPaused = false` flag to `BotInstance`.
|
||||||
|
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
|
||||||
|
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
|
||||||
|
scheduled as today.)
|
||||||
|
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
|
||||||
|
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
|
||||||
|
|
||||||
|
### `autoPaused` bookkeeping (so user pauses are respected)
|
||||||
|
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
|
||||||
|
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
|
||||||
|
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
|
||||||
|
|
||||||
|
### Config wiring
|
||||||
|
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
|
||||||
|
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
|
||||||
|
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
|
||||||
|
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
|
||||||
|
can be as simple as updating the stored config reference / a field the check reads.
|
||||||
|
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
|
||||||
|
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
|
||||||
|
it on save.
|
||||||
|
|
||||||
|
## Components / files
|
||||||
|
|
||||||
|
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
|
||||||
|
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
|
||||||
|
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
|
||||||
|
connect/disconnect; `updateAutoPause(enabled)`.
|
||||||
|
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
|
||||||
|
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
|
||||||
|
- `src/data/config.ts` — field already exists (no change beyond confirming default).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
|
||||||
|
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
|
||||||
|
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
|
||||||
|
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
|
||||||
|
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
|
||||||
|
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
|
||||||
|
persists, propagates).
|
||||||
|
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
|
||||||
|
- Reaction relies on events the bot can already see (same-channel members are always in view);
|
||||||
|
no extra channel subscription needed.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Dedicated-link bot scoping (+ refresh fix) — design
|
||||||
|
|
||||||
|
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
|
||||||
|
**Date:** 2026-05-30
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
|
||||||
|
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
|
||||||
|
|
||||||
|
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
|
||||||
|
|
||||||
|
## Decision (from brainstorm: Q2 = URL-carried scope)
|
||||||
|
|
||||||
|
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Scope state (store)
|
||||||
|
Add to the player store:
|
||||||
|
- `scopedBotId: string | null` — the bot the UI is locked to.
|
||||||
|
- getter `isScoped` = `scopedBotId !== null`.
|
||||||
|
- action `setScope(id)` / `clearScope()`.
|
||||||
|
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
|
||||||
|
|
||||||
|
### URL as the durable source of truth
|
||||||
|
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
|
||||||
|
- **Router `beforeEach` guard** (the heart of it):
|
||||||
|
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
|
||||||
|
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
|
||||||
|
- Else → no scope; continue.
|
||||||
|
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
|
||||||
|
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
|
||||||
|
|
||||||
|
### Exit
|
||||||
|
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
|
||||||
|
|
||||||
|
### Navbar (the lock UI)
|
||||||
|
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
|
||||||
|
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
|
||||||
|
- When not scoped: unchanged (full selector over `controllableBots`).
|
||||||
|
|
||||||
|
### Active-bot coherence
|
||||||
|
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
|
||||||
|
|
||||||
|
## Components / files
|
||||||
|
|
||||||
|
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
|
||||||
|
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
|
||||||
|
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
|
||||||
|
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
|
||||||
|
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
|
||||||
|
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
|
||||||
|
- `clearScope` resets state.
|
||||||
|
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
|
||||||
|
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir } from "./player.js";
|
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
|
||||||
|
|
||||||
function getHeadersArg(args: string[]): string {
|
function getHeadersArg(args: string[]): string {
|
||||||
const idx = args.indexOf("-headers");
|
const idx = args.indexOf("-headers");
|
||||||
@@ -45,6 +45,14 @@ describe("buildFfmpegArgs", () => {
|
|||||||
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
|
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => {
|
||||||
|
const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0);
|
||||||
|
const idx = args.indexOf("-reconnect_at_eof");
|
||||||
|
expect(idx).toBeGreaterThan(-1);
|
||||||
|
expect(args[idx + 1]).toBe("1");
|
||||||
|
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
|
||||||
|
});
|
||||||
|
|
||||||
it("inserts -ss before -i when seekSeconds > 0", () => {
|
it("inserts -ss before -i when seekSeconds > 0", () => {
|
||||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
||||||
const ssIdx = args.indexOf("-ss");
|
const ssIdx = args.indexOf("-ss");
|
||||||
@@ -62,6 +70,7 @@ describe("buildFfmpegArgs", () => {
|
|||||||
it("omits HTTP-only flags when input is a local file path", () => {
|
it("omits HTTP-only flags when input is a local file path", () => {
|
||||||
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
|
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
|
||||||
expect(args).not.toContain("-reconnect");
|
expect(args).not.toContain("-reconnect");
|
||||||
|
expect(args).not.toContain("-reconnect_at_eof");
|
||||||
expect(args).not.toContain("-reconnect_on_network_error");
|
expect(args).not.toContain("-reconnect_on_network_error");
|
||||||
expect(args).not.toContain("-reconnect_on_http_error");
|
expect(args).not.toContain("-reconnect_on_http_error");
|
||||||
expect(args).not.toContain("-headers");
|
expect(args).not.toContain("-headers");
|
||||||
@@ -80,6 +89,37 @@ describe("buildFfmpegArgs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("volumeToFactor (#84 smooth volume curve)", () => {
|
||||||
|
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
|
||||||
|
expect(volumeToFactor(0)).toBe(0);
|
||||||
|
expect(volumeToFactor(100)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps out-of-range input", () => {
|
||||||
|
expect(volumeToFactor(-20)).toBe(0);
|
||||||
|
expect(volumeToFactor(150)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is strictly monotonic across the whole range (no dead zone)", () => {
|
||||||
|
for (let v = 0; v < 100; v++) {
|
||||||
|
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the old flat 80-99 dead zone", () => {
|
||||||
|
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
|
||||||
|
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
|
||||||
|
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the low range gentle", () => {
|
||||||
|
expect(volumeToFactor(50)).toBeLessThan(0.12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("shouldUsePowerShellDownload", () => {
|
describe("shouldUsePowerShellDownload", () => {
|
||||||
const jdymusicUrl =
|
const jdymusicUrl =
|
||||||
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
|
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
|
||||||
@@ -133,3 +173,30 @@ describe("cleanupTempDir", () => {
|
|||||||
expect(() => cleanupTempDir(dir)).not.toThrow();
|
expect(() => cleanupTempDir(dir)).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
|
||||||
|
const MAX_EMPTY = 250; // ~5s near-end threshold
|
||||||
|
const MAX_STALL = 3000; // ~60s far-from-end watchdog
|
||||||
|
|
||||||
|
it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => {
|
||||||
|
expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||||
|
expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => {
|
||||||
|
// This is the core regression: a brief underrun mid-song must not end the track.
|
||||||
|
expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => {
|
||||||
|
// The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence.
|
||||||
|
expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||||
|
expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never ends before any threshold", () => {
|
||||||
|
expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+66
-7
@@ -91,6 +91,11 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
args.push(
|
args.push(
|
||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
|
// Long B站 streams sit on a CDN whose session/token can close the
|
||||||
|
// connection mid-file (premature EOF). Without this, FFmpeg treats that
|
||||||
|
// EOF as end-of-input and stops ~partway through (see #89); with it, it
|
||||||
|
// re-issues a Range request from the current offset to finish the stream.
|
||||||
|
"-reconnect_at_eof", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "30",
|
"-reconnect_delay_max", "30",
|
||||||
"-reconnect_on_network_error", "1",
|
"-reconnect_on_network_error", "1",
|
||||||
@@ -103,6 +108,43 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to end the current track when FFmpeg is still alive but has
|
||||||
|
* produced no decodable audio for `emptyAttempts` consecutive frame ticks.
|
||||||
|
*
|
||||||
|
* - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF.
|
||||||
|
* - Far from the end we wait much longer (`maxStallAttempts`) before giving up,
|
||||||
|
* so a transient buffer underrun on a healthy stream does NOT cause a false
|
||||||
|
* skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session
|
||||||
|
* expired mid-playback, #89) still recovers by advancing instead of going
|
||||||
|
* permanently silent.
|
||||||
|
*/
|
||||||
|
export function shouldEndOnStall(
|
||||||
|
emptyAttempts: number,
|
||||||
|
isNearEnd: boolean,
|
||||||
|
maxEmptyAttempts: number,
|
||||||
|
maxStallAttempts: number,
|
||||||
|
): boolean {
|
||||||
|
if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true;
|
||||||
|
if (emptyAttempts >= maxStallAttempts) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
|
||||||
|
*
|
||||||
|
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
|
||||||
|
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
|
||||||
|
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
|
||||||
|
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
|
||||||
|
* the low end gentle but ramps smoothly toward full loudness near the top, so the
|
||||||
|
* slider feels proportional with no dead zone and no discontinuity at 100.
|
||||||
|
*/
|
||||||
|
export function volumeToFactor(volume: number): number {
|
||||||
|
const x = Math.max(0, Math.min(100, volume)) / 100;
|
||||||
|
return 0.2 * x + 0.8 * Math.pow(x, 8);
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerEvents {
|
export interface PlayerEvents {
|
||||||
frame: (opusFrame: Buffer) => void;
|
frame: (opusFrame: Buffer) => void;
|
||||||
trackEnd: () => void;
|
trackEnd: () => void;
|
||||||
@@ -138,6 +180,11 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private currentTempDir: string | null = null;
|
private currentTempDir: string | null = null;
|
||||||
private emptyFrameAttempts = 0;
|
private emptyFrameAttempts = 0;
|
||||||
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
|
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
|
||||||
|
// Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio
|
||||||
|
// for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream
|
||||||
|
// as dead and advance instead of staying silent forever. Set high so a normal
|
||||||
|
// transient underrun never trips it.
|
||||||
|
private static readonly MAX_STALL_ATTEMPTS = 3000;
|
||||||
private currentSongDuration = 0; // 当前歌曲总时长(秒)
|
private currentSongDuration = 0; // 当前歌曲总时长(秒)
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
@@ -436,16 +483,27 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||||
this.emptyFrameAttempts++;
|
this.emptyFrameAttempts++;
|
||||||
|
|
||||||
// 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束
|
// End the track when FFmpeg has gone silent: quickly if we're near the
|
||||||
if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) {
|
// end (normal EOF), or after a much longer stall window if we're not
|
||||||
this.logger.info({
|
// (a dead/expired stream — #89 — so playback recovers instead of going
|
||||||
|
// permanently silent).
|
||||||
|
if (
|
||||||
|
shouldEndOnStall(
|
||||||
|
this.emptyFrameAttempts,
|
||||||
|
isNearEnd,
|
||||||
|
AudioPlayer.MAX_EMPTY_ATTEMPTS,
|
||||||
|
AudioPlayer.MAX_STALL_ATTEMPTS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.logger.info({
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
emptyAttempts: this.emptyFrameAttempts,
|
emptyAttempts: this.emptyFrameAttempts,
|
||||||
bufferSize: this.pcmBuffer.length,
|
bufferSize: this.pcmBuffer.length,
|
||||||
elapsed: Math.round(elapsed),
|
elapsed: Math.round(elapsed),
|
||||||
duration: this.currentSongDuration,
|
duration: this.currentSongDuration,
|
||||||
remaining: Math.round(this.currentSongDuration - elapsed)
|
remaining: Math.round(this.currentSongDuration - elapsed),
|
||||||
}, "FFmpeg stopped outputting data near end, ending track");
|
nearEnd: isNearEnd,
|
||||||
|
}, "FFmpeg stopped outputting data, ending track");
|
||||||
this.frameLoopRunning = false;
|
this.frameLoopRunning = false;
|
||||||
if (this.state !== "idle") {
|
if (this.state !== "idle") {
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
@@ -509,8 +567,9 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyVolume(pcm: Buffer): Buffer {
|
private applyVolume(pcm: Buffer): Buffer {
|
||||||
if (this.volume === 100) return Buffer.from(pcm);
|
const factor = volumeToFactor(this.volume);
|
||||||
const factor = (this.volume / 100) * 0.2;
|
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
|
||||||
|
if (factor >= 1) return Buffer.from(pcm);
|
||||||
const out = Buffer.alloc(pcm.length);
|
const out = Buffer.alloc(pcm.length);
|
||||||
for (let i = 0; i < pcm.length; i += 2) {
|
for (let i = 0; i < pcm.length; i += 2) {
|
||||||
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
||||||
|
|||||||
@@ -484,4 +484,84 @@ describe("PlayQueue", () => {
|
|||||||
expect(promoted?.id).toBe("x");
|
expect(promoted?.id).toBe("x");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
|
||||||
|
// songs repeated often while others were starved. It should behave like a
|
||||||
|
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
|
||||||
|
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
|
||||||
|
describe("random-loop shuffle bag (issue #70)", () => {
|
||||||
|
it("plays every song exactly once per cycle before repeating", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 12;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
const cycle1 = [queue.current()!.id];
|
||||||
|
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
|
||||||
|
const cycle2: string[] = [];
|
||||||
|
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
|
||||||
|
|
||||||
|
// Each cycle is a full permutation of all N songs — zero repeats within
|
||||||
|
// a cycle, and both cycles cover the same complete set.
|
||||||
|
expect(new Set(cycle1).size).toBe(N);
|
||||||
|
expect(new Set(cycle2).size).toBe(N);
|
||||||
|
expect(new Set(cycle1)).toEqual(new Set(cycle2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 6;
|
||||||
|
const CYCLES = 20;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
counts.set(queue.current()!.id, 1);
|
||||||
|
for (let i = 0; i < CYCLES * N - 1; i++) {
|
||||||
|
const id = queue.next()!.id;
|
||||||
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle bag => each song plays exactly CYCLES times. True random
|
||||||
|
// would skew heavily.
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
expect(counts.get(`s${i}`)).toBe(CYCLES);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replay the same song across a cycle boundary", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 5;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
// Walk to the last song of cycle 1, then cross into cycle 2.
|
||||||
|
for (let i = 0; i < N - 1; i++) queue.next();
|
||||||
|
const lastOfCycle1 = queue.current()!.id;
|
||||||
|
const firstOfCycle2 = queue.next()!.id;
|
||||||
|
expect(firstOfCycle2).not.toBe(lastOfCycle1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a song added mid-cycle within the current cycle", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.play(); // A
|
||||||
|
queue.next(); // B — both originals now played this cycle
|
||||||
|
queue.add(makeSong("C")); // added mid-cycle, still unplayed
|
||||||
|
// C is the only unplayed song, so it must come next (not a reshuffle).
|
||||||
|
expect(queue.next()?.id).toBe("C");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps looping forever with multiple songs (never returns null)", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.add(makeSong("C"));
|
||||||
|
queue.play();
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
expect(queue.next()).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+30
-21
@@ -155,33 +155,42 @@ export class PlayQueue {
|
|||||||
return this.songs[target];
|
return this.songs[target];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 前进栈为空,走纯随机逻辑
|
|
||||||
if (this.mode === PlayMode.Random) {
|
// Shuffle bag: pick uniformly from the songs not yet played this
|
||||||
const unplayed: number[] = [];
|
// cycle, so every song plays once before any repeats (NetEase/QQ
|
||||||
for (let i = 0; i < this.songs.length; i++) {
|
// style). Songs added mid-cycle aren't in playedIndices, so they're
|
||||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
// naturally eligible within the current cycle.
|
||||||
}
|
const unplayed: number[] = [];
|
||||||
if (unplayed.length === 0) return null;
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
const nextIndex =
|
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
}
|
||||||
this.pushHistory(this.currentIndex);
|
|
||||||
this.currentIndex = nextIndex;
|
if (unplayed.length === 0) {
|
||||||
this.playedIndices.add(nextIndex);
|
// Cycle complete.
|
||||||
return this.songs[nextIndex];
|
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
|
||||||
} else {
|
// 随机循环:reshuffle and keep going forever.
|
||||||
if (this.songs.length === 1) {
|
if (this.songs.length === 1) {
|
||||||
this.pushHistory(this.currentIndex);
|
this.pushHistory(this.currentIndex);
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
|
this.playedIndices = new Set([0]);
|
||||||
return this.songs[0];
|
return this.songs[0];
|
||||||
}
|
}
|
||||||
let idx: number;
|
// Start a fresh cycle: every song is eligible again, but exclude
|
||||||
do {
|
// the song that just played from THIS pick only, so it doesn't
|
||||||
idx = Math.floor(Math.random() * this.songs.length);
|
// repeat back-to-back across the boundary. It stays eligible for
|
||||||
} while (idx === this.currentIndex);
|
// the rest of the new cycle, so every song still plays exactly once.
|
||||||
this.pushHistory(this.currentIndex);
|
this.playedIndices = new Set();
|
||||||
this.currentIndex = idx;
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
return this.songs[idx];
|
if (i !== this.currentIndex) unplayed.push(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextIndex =
|
||||||
|
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||||
|
this.pushHistory(this.currentIndex);
|
||||||
|
this.currentIndex = nextIndex;
|
||||||
|
this.playedIndices.add(nextIndex);
|
||||||
|
return this.songs[nextIndex];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
describe("decideOccupancyAction", () => {
|
||||||
|
it("pauses when empty while playing and enabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||||
|
});
|
||||||
|
it("does not pause when the feature is disabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when idle (nothing playing)", () => {
|
||||||
|
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when already paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resumes when re-populated and we auto-paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||||
|
});
|
||||||
|
it("does NOT resume a user-paused track on re-population", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does nothing when re-populated and already playing", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||||
|
export type OccupancyAction = "pause" | "resume" | "none";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what auto-pause should do given channel occupancy.
|
||||||
|
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||||
|
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||||
|
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||||
|
*/
|
||||||
|
export function decideOccupancyAction(
|
||||||
|
playerState: PlayerStateName,
|
||||||
|
autoPaused: boolean,
|
||||||
|
enabled: boolean,
|
||||||
|
userCount: number,
|
||||||
|
): OccupancyAction {
|
||||||
|
const empty = userCount <= 0;
|
||||||
|
if (empty) {
|
||||||
|
if (enabled && playerState === "playing") return "pause";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
if (autoPaused && playerState === "paused") return "resume";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
+109
-26
@@ -17,6 +17,7 @@ import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
|||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
import { BotProfileManager } from "./profile.js";
|
import { BotProfileManager } from "./profile.js";
|
||||||
import type { AvatarStore } from "../data/avatars.js";
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -66,8 +67,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
private isAdvancing = false;
|
private isAdvancing = false;
|
||||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private channelUserCount = 0;
|
private channelUserCount = 0;
|
||||||
|
private autoPaused = false;
|
||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
private isFmMode = false;
|
private isFmMode = false;
|
||||||
|
private fmProvider: MusicProvider | null = null;
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -143,6 +146,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
// A lifecycle change must not leave a stale auto-resume armed.
|
||||||
|
this.autoPaused = false;
|
||||||
// Only emit externally once per lifecycle so clients don't see a
|
// Only emit externally once per lifecycle so clients don't see a
|
||||||
// duplicate "disconnected" after an explicit disconnect() call.
|
// duplicate "disconnected" after an explicit disconnect() call.
|
||||||
if (this.disconnectEmitted) return;
|
if (this.disconnectEmitted) return;
|
||||||
@@ -151,8 +156,26 @@ export class BotInstance extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.tsClient.on("connected", () => {
|
this.tsClient.on("connected", () => {
|
||||||
|
// Fresh connection — clear any stale auto-pause flag from a prior session.
|
||||||
|
this.autoPaused = false;
|
||||||
this._startIdlePoller();
|
this._startIdlePoller();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// React near-instantly to channel membership changes. The 30s idle
|
||||||
|
// poller remains the fallback if any of these events are missed.
|
||||||
|
this.tsClient.on("clientEnter", () => void this.refreshOccupancy());
|
||||||
|
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
|
||||||
|
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshOccupancy(): Promise<void> {
|
||||||
|
if (!this.connected) return;
|
||||||
|
try {
|
||||||
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
|
this.handleOccupancy(clients.length - 1);
|
||||||
|
} catch {
|
||||||
|
// ignore — the 30s poll is the fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
@@ -187,6 +210,16 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (minutes === 0) this._cancelIdleTimer();
|
if (minutes === 0) this._cancelIdleTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 外部更新 autoPauseOnEmpty(由 API 保存时调用) */
|
||||||
|
updateAutoPause(enabled: boolean): void {
|
||||||
|
this.config.autoPauseOnEmpty = enabled;
|
||||||
|
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _startIdlePoller(): void {
|
private _startIdlePoller(): void {
|
||||||
// 每 30 秒检查一次频道人数
|
// 每 30 秒检查一次频道人数
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@@ -194,17 +227,35 @@ export class BotInstance extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const clients = await this.tsClient.getClientsInChannel();
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
const userCount = clients.length - 1; // 排除 bot 自身
|
const userCount = clients.length - 1; // 排除 bot 自身
|
||||||
if (userCount <= 0) {
|
this.handleOccupancy(userCount);
|
||||||
this._scheduleIdleCheck();
|
|
||||||
} else {
|
|
||||||
this._cancelIdleTimer();
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
setTimeout(poll, 30_000);
|
setTimeout(poll, 30_000);
|
||||||
};
|
};
|
||||||
setTimeout(poll, 30_000);
|
setTimeout(poll, 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleOccupancy(userCount: number): void {
|
||||||
|
// idle-disconnect (unchanged behavior)
|
||||||
|
if (userCount <= 0) this._scheduleIdleCheck();
|
||||||
|
else this._cancelIdleTimer();
|
||||||
|
// auto-pause
|
||||||
|
const action = decideOccupancyAction(
|
||||||
|
this.player.getState(),
|
||||||
|
this.autoPaused,
|
||||||
|
this.config.autoPauseOnEmpty,
|
||||||
|
userCount,
|
||||||
|
);
|
||||||
|
if (action === "pause") {
|
||||||
|
this.player.pause();
|
||||||
|
this.autoPaused = true;
|
||||||
|
this.emit("stateChange");
|
||||||
|
} else if (action === "resume") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _scheduleIdleCheck(): void {
|
private _scheduleIdleCheck(): void {
|
||||||
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||||
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||||
@@ -319,7 +370,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
case "album":
|
case "album":
|
||||||
return this.cmdAlbum(cmd);
|
return this.cmdAlbum(cmd);
|
||||||
case "fm":
|
case "fm":
|
||||||
return this.cmdFm();
|
return this.cmdFm(cmd);
|
||||||
case "artist":
|
case "artist":
|
||||||
return this.cmdArtist(cmd);
|
return this.cmdArtist(cmd);
|
||||||
case "vote":
|
case "vote":
|
||||||
@@ -343,6 +394,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private disableFmMode(): void {
|
||||||
|
this.isFmMode = false;
|
||||||
|
this.fmProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
private getProvider(flags: Set<string>): MusicProvider {
|
private getProvider(flags: Set<string>): MusicProvider {
|
||||||
if (flags.has("b")) return this.bilibiliProvider;
|
if (flags.has("b")) return this.bilibiliProvider;
|
||||||
if (flags.has("q")) return this.qqProvider;
|
if (flags.has("q")) return this.qqProvider;
|
||||||
@@ -381,6 +437,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
song.url = url;
|
song.url = url;
|
||||||
this.player.play(url, 0, song.duration);
|
this.player.play(url, 0, song.duration);
|
||||||
|
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
||||||
|
// change won't try to "resume" a track the user already restarted.
|
||||||
|
this.autoPaused = false;
|
||||||
this.database.addPlayHistory({
|
this.database.addPlayHistory({
|
||||||
botId: this.id,
|
botId: this.id,
|
||||||
songId: song.id,
|
songId: song.id,
|
||||||
@@ -390,10 +449,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
platform: song.platform,
|
platform: song.platform,
|
||||||
coverUrl: song.coverUrl,
|
coverUrl: song.coverUrl,
|
||||||
});
|
});
|
||||||
// Update bot presence (fire-and-forget — never blocks playback)
|
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
|
||||||
this.profileManager.onSongChange(song).catch((err) => {
|
await this.syncProfileToSong(song);
|
||||||
this.logger.warn({ err }, "Profile update failed after song change");
|
|
||||||
});
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -402,6 +459,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.profileManager.onSongChange(song);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err }, "Profile update failed after song change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
if (!cmd.args) return "Usage: !play <song name or URL>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
@@ -411,7 +476,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
const song = result.songs[0];
|
const song = result.songs[0];
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
@@ -483,20 +548,25 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private cmdPause(): string {
|
private cmdPause(): string {
|
||||||
this.player.pause();
|
this.player.pause();
|
||||||
|
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
|
||||||
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Paused";
|
return "Paused";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdResume(): string {
|
private cmdResume(): string {
|
||||||
this.player.resume();
|
this.player.resume();
|
||||||
|
// User-initiated resume — drop any auto-pause flag.
|
||||||
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Resumed";
|
return "Resumed";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.autoPaused = false;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||||
});
|
});
|
||||||
@@ -554,7 +624,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdClear(): string {
|
private cmdClear(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||||
});
|
});
|
||||||
@@ -627,7 +697,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (songs.length === 0) return "Playlist is empty or not found";
|
if (songs.length === 0) return "Playlist is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -662,7 +732,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (songs.length === 0) return "Album is empty or not found";
|
if (songs.length === 0) return "Album is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -672,26 +742,38 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdFm(): Promise<string> {
|
private async cmdFm(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!this.neteaseProvider.getPersonalFm) {
|
return this.startFm(this.getProvider(cmd.flags));
|
||||||
return "Personal FM is only available for NetEase Cloud Music";
|
}
|
||||||
|
|
||||||
|
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
|
||||||
|
// Match the !fm chat-command guard: refuse before mutating the queue when
|
||||||
|
// offline, so the web /fm route can't wipe the queue + flip into FM mode
|
||||||
|
// while nothing can actually play.
|
||||||
|
if (!this.connected) {
|
||||||
|
return "Bot is not connected to TeamSpeak";
|
||||||
}
|
}
|
||||||
const songs = await this.neteaseProvider.getPersonalFm();
|
if (!provider.getPersonalFm) {
|
||||||
|
return `Personal FM is not available for ${provider.platform}`;
|
||||||
|
}
|
||||||
|
const songs = await provider.getPersonalFm();
|
||||||
if (songs.length === 0)
|
if (songs.length === 0)
|
||||||
return "No FM songs available (need to login first)";
|
return "No FM songs available (need to login first)";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: "netease" });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
this.queue.setMode(PlayMode.Random);
|
this.queue.setMode(PlayMode.Random);
|
||||||
this.isFmMode = true;
|
this.isFmMode = true;
|
||||||
|
this.fmProvider = provider;
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
|
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) await this.resolveAndPlay(first);
|
if (first) await this.resolveAndPlay(first);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
|
||||||
|
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
||||||
@@ -712,7 +794,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
for (const song of filtered) {
|
for (const song of filtered) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -726,14 +808,15 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refillFm(): Promise<void> {
|
private async refillFm(): Promise<void> {
|
||||||
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
|
const provider = this.fmProvider;
|
||||||
|
if (!this.isFmMode || !provider?.getPersonalFm) return;
|
||||||
try {
|
try {
|
||||||
const songs = await this.neteaseProvider.getPersonalFm();
|
const songs = await provider.getPersonalFm();
|
||||||
if (songs.length === 0) return;
|
if (songs.length === 0) return;
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: "netease" });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
this.logger.debug({ count: songs.length }, "FM queue refilled");
|
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({ err }, "Failed to refill FM queue");
|
this.logger.error({ err }, "Failed to refill FM queue");
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-2
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { getDefaultConfig, loadConfig, saveConfig } from "./config.js";
|
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
|
||||||
|
|
||||||
describe("config", () => {
|
describe("config", () => {
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
@@ -51,4 +51,57 @@ describe("config", () => {
|
|||||||
expect(loaded.commandPrefix).toBe("!");
|
expect(loaded.commandPrefix).toBe("!");
|
||||||
expect(loaded.autoPauseOnEmpty).toBe(true);
|
expect(loaded.autoPauseOnEmpty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- #86: config.json must live under (and be created in) the persisted data dir ---
|
||||||
|
|
||||||
|
it("first run writes config.json into the data dir and reads it back", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const dataDir = join(root, "data");
|
||||||
|
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
|
||||||
|
|
||||||
|
// Boot sequence: load (missing -> defaults) then save.
|
||||||
|
const config = loadConfig(configPath);
|
||||||
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
expect(existsSync(configPath)).toBe(true);
|
||||||
|
// A subsequent hand-edited file under the SAME persisted path is honored.
|
||||||
|
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
|
||||||
|
expect(loadConfig(configPath).webPort).toBe(9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates a legacy root config into the data dir, preserving values", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const legacyPath = join(root, "config.json");
|
||||||
|
const newPath = join(root, "data", "config.json");
|
||||||
|
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
|
||||||
|
|
||||||
|
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
||||||
|
|
||||||
|
expect(migrated).toBe(true);
|
||||||
|
expect(existsSync(newPath)).toBe(true);
|
||||||
|
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
|
||||||
|
const loaded = loadConfig(newPath);
|
||||||
|
expect(loaded.webPort).toBe(4242);
|
||||||
|
expect(loaded.publicUrl).toBe("http://x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT overwrite an existing data-dir config during migration", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const legacyPath = join(root, "config.json");
|
||||||
|
const newPath = join(root, "data", "config.json");
|
||||||
|
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
|
||||||
|
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
|
||||||
|
|
||||||
|
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
||||||
|
|
||||||
|
expect(migrated).toBe(false); // new location wins, untouched
|
||||||
|
expect(loadConfig(newPath).webPort).toBe(2222);
|
||||||
|
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migration is a no-op when there is no legacy config", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
|
||||||
|
expect(migrated).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+33
-1
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
export interface BotConfig {
|
export interface BotConfig {
|
||||||
@@ -58,3 +58,35 @@ export function saveConfig(path: string, config: BotConfig): void {
|
|||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
|
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration for the config location fix (#86).
|
||||||
|
*
|
||||||
|
* Older versions wrote config.json to the app/repo ROOT, which is NOT inside the
|
||||||
|
* persisted data directory (the Docker volume is mounted at data/). That meant the
|
||||||
|
* file never landed in the volume on first run and a manually-placed data/config.json
|
||||||
|
* was ignored. config.json now lives under the data dir alongside the DB/cookies/logs.
|
||||||
|
*
|
||||||
|
* If a legacy root-level config exists and the new data-dir config does not yet exist,
|
||||||
|
* move it so existing local installs keep their customized settings. Best-effort:
|
||||||
|
* any failure is swallowed and loadConfig falls back to defaults.
|
||||||
|
*
|
||||||
|
* @returns true if a legacy config was migrated, false otherwise.
|
||||||
|
*/
|
||||||
|
export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean {
|
||||||
|
try {
|
||||||
|
if (legacyPath === newPath) return false;
|
||||||
|
if (existsSync(newPath)) return false; // new location already populated — leave it
|
||||||
|
if (!existsSync(legacyPath)) return false; // nothing to migrate
|
||||||
|
mkdirSync(dirname(newPath), { recursive: true });
|
||||||
|
copyFileSync(legacyPath, newPath); // copy first (works across filesystems)
|
||||||
|
try {
|
||||||
|
rmSync(legacyPath);
|
||||||
|
} catch {
|
||||||
|
/* leave the legacy file if it can't be removed; the new one wins */
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,17 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
|||||||
nowPlayingMsgEnabled: true,
|
nowPlayingMsgEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface FavoritePlaylist {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
platform: string;
|
||||||
|
playlistId: string;
|
||||||
|
name: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotDatabase {
|
export interface BotDatabase {
|
||||||
db: Database.Database;
|
db: Database.Database;
|
||||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||||
@@ -63,6 +74,10 @@ export interface BotDatabase {
|
|||||||
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||||
getCustomAvatarPath(botId: string): string | null;
|
getCustomAvatarPath(botId: string): string | null;
|
||||||
setCustomAvatarPath(botId: string, path: string | null): void;
|
setCustomAvatarPath(botId: string, path: string | null): void;
|
||||||
|
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
|
||||||
|
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
||||||
|
getFavorites(userId: string): FavoritePlaylist[];
|
||||||
|
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +182,20 @@ function initTables(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
|
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_playlists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
playlistId TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
coverUrl TEXT NOT NULL DEFAULT '',
|
||||||
|
songCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(userId, platform, playlistId)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||||
userId TEXT NOT NULL,
|
userId TEXT NOT NULL,
|
||||||
permission TEXT NOT NULL,
|
permission TEXT NOT NULL,
|
||||||
@@ -266,6 +295,24 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
|
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
|
||||||
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
|
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
|
||||||
|
|
||||||
|
const insertFavorite = db.prepare(`
|
||||||
|
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
|
||||||
|
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteFavorite = db.prepare(`
|
||||||
|
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectFavorites = db.prepare(`
|
||||||
|
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
|
||||||
|
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const checkFavorited = db.prepare(`
|
||||||
|
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
||||||
|
`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|
||||||
@@ -337,6 +384,24 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
updateCustomAvatar.run(path, botId);
|
updateCustomAvatar.run(path, botId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addFavorite(userId, playlist) {
|
||||||
|
insertFavorite.run({ userId, ...playlist });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFavorite(userId, playlistId, platform) {
|
||||||
|
const result = deleteFavorite.run(userId, playlistId, platform);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
getFavorites(userId) {
|
||||||
|
return selectFavorites.all(userId) as FavoritePlaylist[];
|
||||||
|
},
|
||||||
|
|
||||||
|
isFavorited(userId, playlistId, platform) {
|
||||||
|
const row = checkFavorited.get(userId, playlistId, platform);
|
||||||
|
return row !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadConfig, saveConfig } from "./data/config.js";
|
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js";
|
||||||
import { createDatabase } from "./data/database.js";
|
import { createDatabase } from "./data/database.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { createApiServerManager } from "./music/api-server.js";
|
import { createApiServerManager } from "./music/api-server.js";
|
||||||
@@ -16,7 +16,11 @@ import { createWebServer } from "./web/server.js";
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||||
const DATA_DIR = path.join(ROOT_DIR, "data");
|
const DATA_DIR = path.join(ROOT_DIR, "data");
|
||||||
const CONFIG_PATH = path.join(ROOT_DIR, "config.json");
|
// config.json lives under the persisted data dir (the Docker volume) alongside the
|
||||||
|
// DB/cookies/logs, so it survives container restarts and manual edits take effect
|
||||||
|
// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once.
|
||||||
|
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
|
||||||
|
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json");
|
||||||
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
|
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
|
||||||
const LOG_DIR = path.join(DATA_DIR, "logs");
|
const LOG_DIR = path.join(DATA_DIR, "logs");
|
||||||
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
||||||
@@ -24,6 +28,9 @@ const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
|||||||
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// Migrate a pre-#86 root-level config.json into the data dir so existing
|
||||||
|
// installs keep their settings; no-op if already migrated or none exists.
|
||||||
|
migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH);
|
||||||
const config = loadConfig(CONFIG_PATH);
|
const config = loadConfig(CONFIG_PATH);
|
||||||
saveConfig(CONFIG_PATH, config);
|
saveConfig(CONFIG_PATH, config);
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -1,7 +1,31 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { mapQqAlbums } from "./qq.js";
|
import { mapQqAlbums, mapQqSongs } from "./qq.js";
|
||||||
|
|
||||||
describe("QQ adapter", () => {
|
describe("QQ adapter", () => {
|
||||||
|
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||||
|
const out = mapQqSongs([
|
||||||
|
{
|
||||||
|
mid: "001abc",
|
||||||
|
name: "Radar Song",
|
||||||
|
singer: [{ name: "Singer A" }, { name: "Singer B" }],
|
||||||
|
album: { name: "Album A", mid: "alb001" },
|
||||||
|
interval: 243,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
id: "001abc",
|
||||||
|
name: "Radar Song",
|
||||||
|
artist: "Singer A / Singer B",
|
||||||
|
album: "Album A",
|
||||||
|
duration: 243,
|
||||||
|
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
|
||||||
|
platform: "qq",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
||||||
const raw = [
|
const raw = [
|
||||||
{
|
{
|
||||||
|
|||||||
+109
-46
@@ -39,6 +39,24 @@ const qqFavApi = axios.create({
|
|||||||
headers: { referer: "https://y.qq.com/" },
|
headers: { referer: "https://y.qq.com/" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((s) => {
|
||||||
|
const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? "";
|
||||||
|
return {
|
||||||
|
id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""),
|
||||||
|
name: s.title ?? s.name ?? s.songname ?? "",
|
||||||
|
artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
|
||||||
|
album: s.album?.name ?? s.album?.title ?? s.albumname ?? "",
|
||||||
|
duration: s.interval ?? Math.round((s.duration ?? 0) / 1000),
|
||||||
|
coverUrl: albumMid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
||||||
|
: "",
|
||||||
|
platform: "qq" as const,
|
||||||
|
};
|
||||||
|
}).filter((s) => s.id);
|
||||||
|
}
|
||||||
|
|
||||||
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.map((a) => {
|
return raw.map((a) => {
|
||||||
@@ -72,6 +90,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
private cookie = "";
|
private cookie = "";
|
||||||
private quality = "exhigh";
|
private quality = "exhigh";
|
||||||
|
private radarPage = 1;
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
@@ -92,6 +111,30 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
return this.cookie ? { cookie: this.cookie } : {};
|
return this.cookie ? { cookie: this.cookie } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get directCookieHeaders(): Record<string, string> {
|
||||||
|
return this.cookie ? { Cookie: this.cookie } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie);
|
||||||
|
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
|
||||||
|
return {
|
||||||
|
comm: {
|
||||||
|
ct: 24,
|
||||||
|
cv: 4747474,
|
||||||
|
platform: "yqq.json",
|
||||||
|
uin: uinMatch ? uinMatch[1] : "0",
|
||||||
|
g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381,
|
||||||
|
format: "json",
|
||||||
|
inCharset: "utf-8",
|
||||||
|
outCharset: "utf-8",
|
||||||
|
notice: 0,
|
||||||
|
need_new_code: 1,
|
||||||
|
},
|
||||||
|
req_0: { module, method, param },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
||||||
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||||
@@ -141,17 +184,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
res.data?.req_0?.data?.body?.song?.list ?? [];
|
res.data?.req_0?.data?.body?.song?.list ?? [];
|
||||||
if (songList.length === 0) return null;
|
if (songList.length === 0) return null;
|
||||||
|
|
||||||
const songs: Song[] = songList.map((s: any) => ({
|
const songs = mapQqSongs(songList);
|
||||||
id: String(s.mid ?? s.id),
|
|
||||||
name: s.title ?? s.name ?? "",
|
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
|
||||||
album: s.album?.name ?? s.album?.title ?? "",
|
|
||||||
duration: s.interval ?? 0,
|
|
||||||
coverUrl: s.album?.mid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
|
||||||
: "",
|
|
||||||
platform: "qq",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
||||||
const albums = mapQqAlbums(albumList);
|
const albums = mapQqAlbums(albumList);
|
||||||
@@ -203,17 +236,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
? (songRes.value.data?.data?.song?.list ?? [])
|
? (songRes.value.data?.data?.song?.list ?? [])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const songs: Song[] = songList.map((s: any) => ({
|
const songs = mapQqSongs(songList);
|
||||||
id: String(s.songmid ?? s.songid ?? ""),
|
|
||||||
name: s.songname ?? s.name ?? "",
|
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
|
||||||
album: s.albumname ?? s.album?.name ?? "",
|
|
||||||
duration: s.interval ?? 0,
|
|
||||||
coverUrl: s.albummid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
|
||||||
: "",
|
|
||||||
platform: "qq",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const albumList: any[] =
|
const albumList: any[] =
|
||||||
albumRes.status === "fulfilled"
|
albumRes.status === "fulfilled"
|
||||||
@@ -339,19 +362,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
});
|
});
|
||||||
const cdlist = res.data?.response?.cdlist ?? [];
|
const cdlist = res.data?.response?.cdlist ?? [];
|
||||||
if (cdlist.length === 0) return [];
|
if (cdlist.length === 0) return [];
|
||||||
return (cdlist[0].songlist ?? []).map((s: any) => ({
|
return mapQqSongs(cdlist[0].songlist ?? []);
|
||||||
id: String(s.mid ?? s.songmid ?? s.songid),
|
|
||||||
name: s.songname ?? s.name ?? "",
|
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
|
||||||
album: s.albumname ?? "",
|
|
||||||
duration: s.interval ?? 0,
|
|
||||||
coverUrl: s.album?.mid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
|
||||||
: s.albummid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
|
||||||
: "",
|
|
||||||
platform: "qq",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||||
@@ -386,17 +397,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const res = await this.api.get("/getAlbumInfo", {
|
const res = await this.api.get("/getAlbumInfo", {
|
||||||
params: { albummid: albumId, ...this.cookieParams },
|
params: { albummid: albumId, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
|
return mapQqSongs(res.data?.response?.data?.list ?? []);
|
||||||
id: String(s.songmid ?? s.songid),
|
|
||||||
name: s.songname ?? "",
|
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
|
||||||
album: s.albumname ?? "",
|
|
||||||
duration: s.interval ?? 0,
|
|
||||||
coverUrl: s.albummid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
|
||||||
: "",
|
|
||||||
platform: "qq",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
@@ -461,6 +462,9 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
|
|
||||||
setCookie(cookie: string): void {
|
setCookie(cookie: string): void {
|
||||||
this.cookie = cookie;
|
this.cookie = cookie;
|
||||||
|
// Reset radar pagination so a re-login (different account) starts from the
|
||||||
|
// first page rather than inheriting the previous account's cursor.
|
||||||
|
this.radarPage = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCookie(): string {
|
getCookie(): string {
|
||||||
@@ -522,6 +526,65 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPersonalFm(): Promise<Song[]> {
|
||||||
|
const radarSongs = await this.getRadarRecommendSongs();
|
||||||
|
if (radarSongs.length > 0) return radarSongs;
|
||||||
|
return this.getGuessRecommendSongs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRadarRecommendSongs(): Promise<Song[]> {
|
||||||
|
try {
|
||||||
|
const page = this.radarPage;
|
||||||
|
const res = await qqMusicuApi.post(
|
||||||
|
"/cgi-bin/musicu.fcg",
|
||||||
|
this.buildMusicuPayload(
|
||||||
|
"music.recommend.TrackRelationServer",
|
||||||
|
"GetRadarSong",
|
||||||
|
{
|
||||||
|
Page: page,
|
||||||
|
ReqType: 0,
|
||||||
|
FavSongs: [],
|
||||||
|
EntranceSongs: [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
||||||
|
);
|
||||||
|
const tracks = (res.data?.req_0?.data?.VecSongs ?? [])
|
||||||
|
.map((item: any) => item?.Track)
|
||||||
|
.filter(Boolean);
|
||||||
|
const songs = mapQqSongs(tracks);
|
||||||
|
if (songs.length > 0) {
|
||||||
|
this.radarPage = page + 1;
|
||||||
|
}
|
||||||
|
return songs;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getGuessRecommendSongs(): Promise<Song[]> {
|
||||||
|
try {
|
||||||
|
const res = await qqMusicuApi.post(
|
||||||
|
"/cgi-bin/musicu.fcg",
|
||||||
|
this.buildMusicuPayload(
|
||||||
|
"music.radioProxy.MbTrackRadioSvr",
|
||||||
|
"get_radio_track",
|
||||||
|
{
|
||||||
|
id: 99,
|
||||||
|
num: 5,
|
||||||
|
from: 0,
|
||||||
|
scene: 0,
|
||||||
|
song_ids: [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
||||||
|
);
|
||||||
|
return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getUserPlaylists(): Promise<Playlist[]> {
|
async getUserPlaylists(): Promise<Playlist[]> {
|
||||||
if (!this.cookie) return [];
|
if (!this.cookie) return [];
|
||||||
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type Identity,
|
type Identity,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type ClientInfo,
|
type ClientInfo,
|
||||||
|
type ClientLeftViewEvent,
|
||||||
|
type ClientMovedEvent,
|
||||||
type FileUploadInfo,
|
type FileUploadInfo,
|
||||||
} from "@honeybbq/teamspeak-client";
|
} from "@honeybbq/teamspeak-client";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
@@ -221,6 +223,20 @@ export class TS3Client extends EventEmitter {
|
|||||||
{ nickname: info.nickname, id: info.id },
|
{ nickname: info.nickname, id: info.id },
|
||||||
"Client entered"
|
"Client entered"
|
||||||
);
|
);
|
||||||
|
this.emit("clientEnter", info);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
|
||||||
|
this.logger.debug({ id: ev.id }, "Client left");
|
||||||
|
this.emit("clientLeave", ev);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
|
||||||
|
this.logger.debug(
|
||||||
|
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
|
||||||
|
"Client moved"
|
||||||
|
);
|
||||||
|
this.emit("clientMoved", ev);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore } from "../../data/sessions.js";
|
||||||
|
import { createAvatarStore } from "../../data/avatars.js";
|
||||||
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
|
import { createBotRouter } from "./bot.js";
|
||||||
|
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
|
|
||||||
|
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
|
||||||
|
function makeFakeBot() {
|
||||||
|
return {
|
||||||
|
idleTimeoutCalls: [] as number[],
|
||||||
|
autoPauseCalls: [] as boolean[],
|
||||||
|
updateIdleTimeout(minutes: number) {
|
||||||
|
this.idleTimeoutCalls.push(minutes);
|
||||||
|
},
|
||||||
|
updateAutoPause(enabled: boolean) {
|
||||||
|
this.autoPauseCalls.push(enabled);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bot router /settings", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let app: express.Express;
|
||||||
|
let cookie: string;
|
||||||
|
let config: BotConfig;
|
||||||
|
let configPath: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
let fakeBots: ReturnType<typeof makeFakeBot>[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
const users = createUserStore(botDb.db);
|
||||||
|
const sessions = createSessionStore(botDb.db);
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||||
|
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-"));
|
||||||
|
configPath = join(tmpDir, "config.json");
|
||||||
|
config = { ...getDefaultConfig(), idleTimeoutMinutes: 15, autoPauseOnEmpty: true };
|
||||||
|
|
||||||
|
fakeBots = [makeFakeBot(), makeFakeBot()];
|
||||||
|
const fakeManager = {
|
||||||
|
getAllBots: () => fakeBots,
|
||||||
|
} as unknown as BotManager;
|
||||||
|
const avatarStore = createAvatarStore(tmpDir);
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
|
||||||
|
app.use(
|
||||||
|
"/api/bot",
|
||||||
|
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
botDb.close();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires auth", async () => {
|
||||||
|
const res = await request(app).get("/api/bot/settings");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /settings includes autoPauseOnEmpty reflecting config", async () => {
|
||||||
|
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.idleTimeoutMinutes).toBe(15);
|
||||||
|
expect(res.body.autoPauseOnEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// in-memory config mutated
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(false);
|
||||||
|
|
||||||
|
// propagated to every live bot
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow-up GET reflects the new value
|
||||||
|
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(followUp.body.autoPauseOnEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings still handles idleTimeoutMinutes (no regression)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 42 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(42);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([42]);
|
||||||
|
}
|
||||||
|
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(followUp.body.idleTimeoutMinutes).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings handles both fields together", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 7, autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(7);
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(false);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([7]);
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings with only autoPauseOnEmpty does not touch idleTimeout bots", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([]);
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings ignores non-boolean autoPauseOnEmpty without 400", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 5, autoPauseOnEmpty: "yes" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// idleTimeout still applied
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(5);
|
||||||
|
// autoPause left at its prior value, not propagated
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(true);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.autoPauseCalls).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+45
-25
@@ -27,7 +27,45 @@ export function createBotRouter(
|
|||||||
res.json({ bots });
|
res.json({ bots });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:id", (req, res) => {
|
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||||
|
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route.
|
||||||
|
router.get("/settings", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
|
||||||
|
// behavior is a bot.manage operation, consistent with PR #80's permission model)
|
||||||
|
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
|
||||||
|
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
|
||||||
|
|
||||||
|
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||||
|
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
||||||
|
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
|
||||||
|
|
||||||
|
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||||
|
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||||
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
// 通知所有 bot 实例更新
|
||||||
|
for (const bot of botManager.getAllBots()) {
|
||||||
|
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
|
||||||
|
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id", requireBotAccess("id"), (req, res) => {
|
||||||
const bot = botManager.getBot(req.params.id);
|
const bot = botManager.getBot(req.params.id);
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
res.status(404).json({ error: "Bot not found" });
|
res.status(404).json({ error: "Bot not found" });
|
||||||
@@ -37,16 +75,19 @@ export function createBotRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get saved config for a bot
|
// Get saved config for a bot
|
||||||
router.get("/:id/config", (req, res) => {
|
router.get("/:id/config", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||||
const saved = botManager.getBotConfig(req.params.id);
|
const saved = botManager.getBotConfig(req.params.id);
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
res.status(404).json({ error: "Bot config not found" });
|
res.status(404).json({ error: "Bot config not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json(saved);
|
// Never expose the TS identity / API key to the client; the edit form only
|
||||||
|
// consumes channel/server passwords.
|
||||||
|
const { ts6ApiKey: _ts6ApiKey, identity: _identity, ...safe } = saved as unknown as Record<string, unknown>;
|
||||||
|
res.json(safe);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:id/avatar", (req, res) => {
|
router.get("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||||
const path = botDb.getCustomAvatarPath(req.params.id);
|
const path = botDb.getCustomAvatarPath(req.params.id);
|
||||||
if (!path) {
|
if (!path) {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
@@ -191,27 +232,6 @@ export function createBotRouter(
|
|||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/bot/settings — 读取全局 bot 行为设置
|
|
||||||
router.get("/settings", (_req, res) => {
|
|
||||||
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/bot/settings — 保存全局 bot 行为设置
|
|
||||||
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
|
|
||||||
const { idleTimeoutMinutes } = req.body;
|
|
||||||
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
|
|
||||||
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
config.idleTimeoutMinutes = idleTimeoutMinutes;
|
|
||||||
saveConfig(configPath, config);
|
|
||||||
// 通知所有 bot 实例更新定时器
|
|
||||||
for (const bot of botManager.getAllBots()) {
|
|
||||||
bot.updateIdleTimeout(idleTimeoutMinutes);
|
|
||||||
}
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
|
export function createFavoritesRouter(database: BotDatabase, logger: Logger): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/favorites — 获取当前用户的所有收藏
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const favorites = database.getFavorites(userId);
|
||||||
|
res.json({ favorites });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/favorites — 添加收藏
|
||||||
|
router.post("/", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const { platform, playlistId, name, coverUrl, songCount } = req.body ?? {};
|
||||||
|
if (!platform || !playlistId || !name) {
|
||||||
|
res.status(400).json({ error: "platform, playlistId, name are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
database.addFavorite(userId, {
|
||||||
|
platform,
|
||||||
|
playlistId,
|
||||||
|
name,
|
||||||
|
coverUrl: coverUrl ?? "",
|
||||||
|
songCount: songCount ?? 0,
|
||||||
|
});
|
||||||
|
logger.info({ userId, platform, playlistId, name }, "Playlist favorited");
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { code?: string };
|
||||||
|
if (e?.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||||
|
res.status(409).json({ error: "already favorited" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error({ err }, "Failed to add favorite");
|
||||||
|
res.status(500).json({ error: "internal error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/favorites/:id — 取消收藏(只允许删除自己的)
|
||||||
|
router.delete("/:id", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const favId = parseInt(req.params.id, 10);
|
||||||
|
if (isNaN(favId)) {
|
||||||
|
res.status(400).json({ error: "invalid id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const favorites = database.getFavorites(userId);
|
||||||
|
const fav = favorites.find((f) => f.id === favId);
|
||||||
|
if (!fav) {
|
||||||
|
res.status(404).json({ error: "favorite not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
database.removeFavorite(userId, fav.playlistId, fav.platform);
|
||||||
|
logger.info({ userId, playlistId: fav.playlistId, platform: fav.platform }, "Playlist unfavorited");
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/favorites/check?platform=netease&playlistId=xxx — 检查是否已收藏
|
||||||
|
router.get("/check", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const { platform, playlistId } = req.query;
|
||||||
|
if (typeof platform !== "string" || typeof playlistId !== "string") {
|
||||||
|
res.status(400).json({ error: "platform and playlistId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const favorited = database.isFavorited(userId, playlistId, platform);
|
||||||
|
res.json({ favorited });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -95,6 +95,28 @@ export function createPlayerRouter(
|
|||||||
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
|
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
|
||||||
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear"));
|
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear"));
|
||||||
|
|
||||||
|
router.post("/:botId/fm", requirePermission("player.control"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const { platform } = req.body;
|
||||||
|
const provider = bot.getProviderFor(
|
||||||
|
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||||
|
? platform
|
||||||
|
: "netease"
|
||||||
|
);
|
||||||
|
const message = await bot.startFm(provider);
|
||||||
|
res.json({
|
||||||
|
ok:
|
||||||
|
!message.startsWith("No FM songs") &&
|
||||||
|
!message.includes("not available") &&
|
||||||
|
!message.includes("not connected"),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
|
|||||||
@@ -55,4 +55,19 @@ describe("csrfOriginCheck middleware", () => {
|
|||||||
.set("Referer", "https://evil.com/some/path");
|
.set("Referer", "https://evil.com/some/path");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Documents the server side of the QR-login outage: a `no-referrer` document
|
||||||
|
// policy makes the browser send the literal `Origin: null` on same-origin
|
||||||
|
// POSTs, which this guard cannot parse a host from and therefore rejects.
|
||||||
|
// The fix lives in the frontend (referrer policy -> same-origin); this test
|
||||||
|
// pins the gate behavior so the interaction stays understood. See
|
||||||
|
// src/web/referrer-policy.test.ts.
|
||||||
|
it('rejects POST with the literal Origin: "null" (no-referrer downgrade)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/")
|
||||||
|
.set("Host", "example.com")
|
||||||
|
.set("Origin", "null");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body).toEqual({ error: "bad origin" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression guard for the QR-login / cookie-save outage (and in fact every
|
||||||
|
* mutating WebUI action). On 2026-05-27 the WebUI-auth feature added the
|
||||||
|
* same-origin CSRF gate `app.use("/api", csrfOriginCheck)` in server.ts, and
|
||||||
|
* the same day a `<meta name="referrer" content="no-referrer">` was added to
|
||||||
|
* web/index.html so cross-origin CDN cover thumbnails would load.
|
||||||
|
*
|
||||||
|
* Those two changes conflict: per the WHATWG Fetch "Append a request Origin
|
||||||
|
* header" algorithm, the `no-referrer` policy sets the Origin header to the
|
||||||
|
* literal string "null" on same-origin non-GET requests. csrfOriginCheck then
|
||||||
|
* fails to parse a host (`new URL("null")` throws) and returns 403 "bad
|
||||||
|
* origin", so POST /api/auth/qrcode (and every other POST/PUT/DELETE under
|
||||||
|
* /api/* except /api/session/*) never reaches its handler.
|
||||||
|
*
|
||||||
|
* `same-origin` is the correct policy: it keeps the real Origin on same-origin
|
||||||
|
* requests (CSRF passes) while still sending no Referer cross-origin (CDN
|
||||||
|
* thumbnails keep loading). Never switch this back to `no-referrer`.
|
||||||
|
*/
|
||||||
|
describe("frontend referrer policy (CSRF / Origin-header regression)", () => {
|
||||||
|
const indexHtmlPath = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../web/index.html"
|
||||||
|
);
|
||||||
|
const html = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||||
|
|
||||||
|
const referrerMeta = html.match(
|
||||||
|
/<meta\s+name=["']referrer["']\s+content=["']([^"']+)["']\s*\/?>/i
|
||||||
|
);
|
||||||
|
|
||||||
|
it("declares a referrer policy meta tag", () => {
|
||||||
|
expect(referrerMeta).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses same-origin (NOT no-referrer, which sends Origin: null and 403s every POST)", () => {
|
||||||
|
expect(referrerMeta?.[1]).toBe("same-origin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not contain no-referrer anywhere in the document head", () => {
|
||||||
|
expect(html).not.toMatch(/content=["']no-referrer["']/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import { createSessionRouter } from "./api/session.js";
|
|||||||
import { createUsersRouter } from "./api/users.js";
|
import { createUsersRouter } from "./api/users.js";
|
||||||
import { createAuditStore } from "../data/audit.js";
|
import { createAuditStore } from "../data/audit.js";
|
||||||
import { createAuditRouter } from "./api/audit.js";
|
import { createAuditRouter } from "./api/audit.js";
|
||||||
|
import { createFavoritesRouter } from "./api/favorites.js";
|
||||||
import { setupWebSocket } from "./websocket.js";
|
import { setupWebSocket } from "./websocket.js";
|
||||||
import { createUserStore } from "../data/users.js";
|
import { createUserStore } from "../data/users.js";
|
||||||
import { createSessionStore } from "../data/sessions.js";
|
import { createSessionStore } from "../data/sessions.js";
|
||||||
@@ -125,6 +126,8 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
"/api/auth",
|
"/api/auth",
|
||||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||||
);
|
);
|
||||||
|
app.use("/api/favorites", createFavoritesRouter(options.database, logger));
|
||||||
|
|
||||||
// admin-only routes
|
// admin-only routes
|
||||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
||||||
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
|
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
title TSMusicBot
|
||||||
|
|
||||||
|
:: Check node
|
||||||
|
where node >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Node.js not found. Run scripts\setup.bat first.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting TSMusicBot...
|
||||||
|
echo WebUI: http://localhost:3000
|
||||||
|
echo Press Ctrl+C to stop.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
node dist\index.js
|
||||||
|
|
||||||
|
pause
|
||||||
+12
-4
@@ -3,10 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on their whitelist.
|
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
|
||||||
Setting no-referrer at the document level covers <img> tags AND CSS background-image fetches.
|
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
|
||||||
Our own /api/* CSRF check uses Origin (not Referer), so this doesn't break auth. -->
|
does exactly that: full Referer for our own requests, none for cross-origin
|
||||||
<meta name="referrer" content="no-referrer">
|
ones — so cover thumbnails (<img> AND CSS background-image) still load.
|
||||||
|
Do NOT switch this back to "no-referrer": per the WHATWG Fetch spec
|
||||||
|
("Append a request Origin header") no-referrer downgrades the Origin header
|
||||||
|
to the literal string "null" on same-origin non-GET requests. The /api/*
|
||||||
|
CSRF guard (src/web/middleware/csrf.ts) then can't parse a host from it and
|
||||||
|
responds 403 "bad origin", silently breaking EVERY POST/PUT/DELETE — QR
|
||||||
|
login, cookie save, playback controls, bot management, user admin, etc.
|
||||||
|
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
|
||||||
|
<meta name="referrer" content="same-origin">
|
||||||
<title>TSMusicBot</title>
|
<title>TSMusicBot</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|||||||
+14
-2
@@ -140,12 +140,24 @@ function cycleMobileMode() {
|
|||||||
playerStore.setMode(nextMode);
|
playerStore.setMode(nextMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
playerStore.loadTheme();
|
playerStore.loadTheme();
|
||||||
connect();
|
connect();
|
||||||
playerStore.fetchBots();
|
// Hydrate favorites once per session so deep-links / hard refreshes onto
|
||||||
|
// Search or Playlist render hearts correctly without first visiting Home.
|
||||||
|
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
|
||||||
|
playerStore.fetchFavorites();
|
||||||
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
||||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||||
|
// Reconcile the dedicated-link scope only after the bot list is known: the
|
||||||
|
// router guard sets scopedBotId tentatively from ?bot, but applyScopeFromQuery
|
||||||
|
// validates it against the loaded bots (locks if it exists, clears if stale).
|
||||||
|
await playerStore.fetchBots();
|
||||||
|
// Read from the authoritative current route (not a possibly-stale reactive
|
||||||
|
// snapshot) so the scope reconciles against the ?bot present at refresh time.
|
||||||
|
const routeBot = router.currentRoute.value.query.bot;
|
||||||
|
const qBot = typeof routeBot === 'string' ? routeBot : null;
|
||||||
|
playerStore.applyScopeFromQuery(qBot);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -10,8 +10,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<!-- Bot selector (always shown when at least one controllable bot exists) -->
|
<!-- Scoped (dedicated link): static label locked to the one bot, no switching -->
|
||||||
<div v-if="controllableBots.length > 0" class="bot-selector" ref="selectorRef">
|
<div v-if="store.isScoped" class="bot-selector scoped" ref="selectorRef">
|
||||||
|
<div class="bot-selector-btn static">
|
||||||
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
|
<span class="bot-selector-name">{{ activeBot?.name ?? '专属机器人' }}</span>
|
||||||
|
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
|
||||||
|
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
|
||||||
|
<span class="scope-badge">专属模式</span>
|
||||||
|
</div>
|
||||||
|
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal: full selector with switching (shown when at least one
|
||||||
|
controllable bot exists — scope ∩ permission via displayedBots) -->
|
||||||
|
<div v-else-if="displayedBots.length > 0" class="bot-selector" ref="selectorRef">
|
||||||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
||||||
@@ -22,7 +35,7 @@
|
|||||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||||
<div class="bot-dropdown-header">机器人</div>
|
<div class="bot-dropdown-header">机器人</div>
|
||||||
<div
|
<div
|
||||||
v-for="bot in controllableBots"
|
v-for="bot in displayedBots"
|
||||||
:key="bot.id"
|
:key="bot.id"
|
||||||
class="bot-card"
|
class="bot-card"
|
||||||
:class="{ active: bot.id === store.activeBotId }"
|
:class="{ active: bot.id === store.activeBotId }"
|
||||||
@@ -143,6 +156,15 @@ async function onLogout() {
|
|||||||
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
|
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
|
||||||
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
|
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
|
||||||
const activeBot = computed(() => store.activeBot);
|
const activeBot = computed(() => store.activeBot);
|
||||||
|
// The bots shown in the selector are the INTERSECTION of the permission
|
||||||
|
// allow-list (controllableBots) and the dedicated-link scope: while scoped the
|
||||||
|
// selector is locked to the single scoped bot, otherwise the full controllable
|
||||||
|
// list is shown and switching is allowed.
|
||||||
|
const displayedBots = computed(() =>
|
||||||
|
store.isScoped
|
||||||
|
? controllableBots.value.filter((b) => b.id === store.scopedBotId)
|
||||||
|
: controllableBots.value,
|
||||||
|
);
|
||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
const selectorRef = ref<HTMLElement | null>(null);
|
const selectorRef = ref<HTMLElement | null>(null);
|
||||||
const togglingBots = ref<Record<string, boolean>>({});
|
const togglingBots = ref<Record<string, boolean>>({});
|
||||||
@@ -161,6 +183,14 @@ function selectBot(id: string) {
|
|||||||
dropdownOpen.value = false;
|
dropdownOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leave dedicated-link mode. Clear scope BEFORE navigating so the router guard
|
||||||
|
// (which re-attaches ?bot from scopedBotId) sees a null scope and lets us out.
|
||||||
|
function exitScope() {
|
||||||
|
store.clearScope();
|
||||||
|
dropdownOpen.value = false;
|
||||||
|
navRouter.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBaseUrl(): string {
|
function resolveBaseUrl(): string {
|
||||||
const base = publicBaseUrl.value;
|
const base = publicBaseUrl.value;
|
||||||
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||||||
@@ -375,6 +405,60 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scoped (dedicated-link) selector: locked, non-interactive label + exit */
|
||||||
|
.bot-selector.scoped {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector-btn.static {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-primary-15);
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-exit-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bot-state-mini {
|
.bot-state-mini {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
&.playing { color: var(--color-online); }
|
&.playing { color: var(--color-online); }
|
||||||
|
|||||||
+7
-1
@@ -11,4 +11,10 @@ installApiClient();
|
|||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount('#app');
|
// Wait for the initial navigation (and the beforeEach guard that reads ?bot)
|
||||||
|
// to fully resolve before mounting, so the reactive route query is populated
|
||||||
|
// when App.onMounted runs and the dedicated-bot scope locks the right bot.
|
||||||
|
// .catch keeps parity with the old unconditional mount: if the initial
|
||||||
|
// navigation errors (e.g. a transient network failure in the auth guard),
|
||||||
|
// still render the shell rather than leaving a blank page.
|
||||||
|
router.isReady().catch(() => {}).then(() => app.mount('#app'));
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { useSession } from '../composables/useSession.js';
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -56,6 +57,22 @@ router.beforeEach(async (to) => {
|
|||||||
if (!session.isAuthenticated.value) {
|
if (!session.isAuthenticated.value) {
|
||||||
return { name: 'login', query: { next: to.fullPath } };
|
return { name: 'login', query: { next: to.fullPath } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigation is allowed to proceed to `to` past here (auth/setup redirects above take precedence).
|
||||||
|
// Sync + preserve the dedicated-link scope carried by ?bot.
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const qBot = typeof to.query.bot === 'string' && to.query.bot ? to.query.bot : null;
|
||||||
|
if (qBot) {
|
||||||
|
// URL carries a scope — set tentatively; App.vue's applyScopeFromQuery (after fetchBots) validates/clears it.
|
||||||
|
store.scopedBotId = qBot;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (store.scopedBotId) {
|
||||||
|
// scoped, but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh.
|
||||||
|
if (to.query.bot !== store.scopedBotId) {
|
||||||
|
return { path: to.path, query: { ...to.query, bot: store.scopedBotId }, hash: to.hash };
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { resolveScopedBot } from './scope.js';
|
||||||
|
|
||||||
export interface Song {
|
export interface Song {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +35,17 @@ export interface PlaylistItem {
|
|||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FavoritePlaylist {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
platform: string;
|
||||||
|
playlistId: string;
|
||||||
|
name: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TimingState {
|
interface TimingState {
|
||||||
serverElapsed: number;
|
serverElapsed: number;
|
||||||
serverSyncTime: number;
|
serverSyncTime: number;
|
||||||
@@ -50,6 +62,9 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
bots: [] as BotStatus[],
|
bots: [] as BotStatus[],
|
||||||
activeBotId: null as string | null,
|
activeBotId: null as string | null,
|
||||||
|
/** When set, the UI is locked to a single bot (dedicated link, from ?bot).
|
||||||
|
* Source of truth is the URL — never persisted to localStorage. */
|
||||||
|
scopedBotId: null as string | null,
|
||||||
/** Per-bot queues keyed by botId */
|
/** Per-bot queues keyed by botId */
|
||||||
queues: {} as Record<string, Song[]>,
|
queues: {} as Record<string, Song[]>,
|
||||||
/** Per-bot timing state keyed by botId */
|
/** Per-bot timing state keyed by botId */
|
||||||
@@ -64,6 +79,9 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
authStatus: { netease: false, qq: false },
|
authStatus: { netease: false, qq: false },
|
||||||
lastFetchTime: 0,
|
lastFetchTime: 0,
|
||||||
|
|
||||||
|
// Favorited playlists (fetched from server, isolated per WebUI user)
|
||||||
|
favoritedPlaylists: [] as FavoritePlaylist[],
|
||||||
|
|
||||||
// Transient notification for surfacing failures (e.g., "song not playable")
|
// Transient notification for surfacing failures (e.g., "song not playable")
|
||||||
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
||||||
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
||||||
@@ -73,6 +91,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
activeBot(): BotStatus | null {
|
activeBot(): BotStatus | null {
|
||||||
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
|
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
|
||||||
},
|
},
|
||||||
|
/** True when the UI is locked to a single bot via a dedicated link. */
|
||||||
|
isScoped(): boolean {
|
||||||
|
return this.scopedBotId !== null;
|
||||||
|
},
|
||||||
currentSong(): Song | null {
|
currentSong(): Song | null {
|
||||||
return this.activeBot?.currentSong ?? null;
|
return this.activeBot?.currentSong ?? null;
|
||||||
},
|
},
|
||||||
@@ -125,6 +147,8 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setActiveBotId(id: string) {
|
setActiveBotId(id: string) {
|
||||||
|
// While scoped to a dedicated link, switching bots is blocked.
|
||||||
|
if (this.scopedBotId !== null && id !== this.scopedBotId) return;
|
||||||
this.activeBotId = id;
|
this.activeBotId = id;
|
||||||
// Fetch queue for newly active bot if we don't have it yet
|
// Fetch queue for newly active bot if we don't have it yet
|
||||||
if (!this.queues[id]) {
|
if (!this.queues[id]) {
|
||||||
@@ -132,6 +156,32 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Lock the UI to a single bot (dedicated link). Sets scope first so the
|
||||||
|
* setActiveBotId guard does not block the switch to the scoped bot. */
|
||||||
|
setScope(id: string) {
|
||||||
|
this.scopedBotId = id;
|
||||||
|
this.activeBotId = id;
|
||||||
|
// Lazily fetch this bot's queue, mirroring setActiveBotId.
|
||||||
|
if (!this.queues[id]) {
|
||||||
|
this.fetchQueue();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearScope() {
|
||||||
|
this.scopedBotId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reconcile the scope with the desired id from the URL (?bot). A stale or
|
||||||
|
* forbidden id resolves to null and clears the scope rather than locking. */
|
||||||
|
applyScopeFromQuery(requestedId: string | null) {
|
||||||
|
const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id));
|
||||||
|
if (r) {
|
||||||
|
this.setScope(r);
|
||||||
|
} else if (requestedId) {
|
||||||
|
this.clearScope();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
updateBotStatus(botId: string, status: BotStatus) {
|
updateBotStatus(botId: string, status: BotStatus) {
|
||||||
const prev = this.bots.find((b) => b.id === botId);
|
const prev = this.bots.find((b) => b.id === botId);
|
||||||
const prevSongId = prev?.currentSong?.id;
|
const prevSongId = prev?.currentSong?.id;
|
||||||
@@ -166,6 +216,11 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.bots = this.bots.filter((b) => b.id !== botId);
|
this.bots = this.bots.filter((b) => b.id !== botId);
|
||||||
delete this.queues[botId];
|
delete this.queues[botId];
|
||||||
delete this.timings[botId];
|
delete this.timings[botId];
|
||||||
|
// If the bot we were locked to is gone, drop the scope so the UI does not
|
||||||
|
// stay 'locked' onto a phantom (activeBot would silently fall back to bots[0]).
|
||||||
|
if (this.scopedBotId === botId) {
|
||||||
|
this.clearScope();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setQueue(botId: string, queue: Song[]) {
|
setQueue(botId: string, queue: Song[]) {
|
||||||
@@ -397,6 +452,60 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
if (bot) bot.playMode = mode;
|
if (bot) bot.playMode = mode;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async startFm(platform: Source = 'netease') {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform });
|
||||||
|
if (res.data?.message) {
|
||||||
|
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||||
|
}
|
||||||
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
|
this._syncAfterAction();
|
||||||
|
this.fetchQueue();
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchFavorites() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/favorites');
|
||||||
|
this.favoritedPlaylists = res.data.favorites ?? [];
|
||||||
|
} catch {
|
||||||
|
// not critical
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/favorites', playlist);
|
||||||
|
await this.fetchFavorites();
|
||||||
|
this.notify('已收藏', 'info');
|
||||||
|
} catch (err: any) {
|
||||||
|
// 409 = already favorited (e.g. stale heart); just resync so the UI converges.
|
||||||
|
if (err?.response?.status === 409) {
|
||||||
|
await this.fetchFavorites();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.notify('收藏失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFavorite(id: number) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/favorites/${id}`);
|
||||||
|
await this.fetchFavorites();
|
||||||
|
this.notify('已取消收藏', 'info');
|
||||||
|
} catch (err: any) {
|
||||||
|
// 404 = already gone; resync. Otherwise report failure.
|
||||||
|
if (err?.response?.status === 404) {
|
||||||
|
await this.fetchFavorites();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.notify('取消收藏失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isFavorited(playlistId: string, platform: string): boolean {
|
||||||
|
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
|
||||||
|
},
|
||||||
|
|
||||||
async fetchHomeData() {
|
async fetchHomeData() {
|
||||||
// Always check auth status first — if it changed since the cached
|
// Always check auth status first — if it changed since the cached
|
||||||
// fetch (e.g., user logged in/out as a different account), the
|
// fetch (e.g., user logged in/out as a different account), the
|
||||||
@@ -414,6 +523,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.authStatus.netease = newAuth.netease;
|
this.authStatus.netease = newAuth.netease;
|
||||||
this.authStatus.qq = newAuth.qq;
|
this.authStatus.qq = newAuth.qq;
|
||||||
|
|
||||||
|
// Favorites are user-local and cheap; always refresh them, even on a
|
||||||
|
// home-data cache hit, so hearts stay correct across tabs/sessions.
|
||||||
|
this.fetchFavorites();
|
||||||
|
|
||||||
// Cache hit only if auth is unchanged AND within TTL.
|
// Cache hit only if auth is unchanged AND within TTL.
|
||||||
if (
|
if (
|
||||||
!authChanged &&
|
!authChanged &&
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveScopedBot } from "./scope.js";
|
||||||
|
|
||||||
|
describe("resolveScopedBot", () => {
|
||||||
|
it("returns null when no id requested", () => {
|
||||||
|
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
|
||||||
|
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
|
||||||
|
expect(resolveScopedBot("", ["a"])).toBeNull();
|
||||||
|
});
|
||||||
|
it("returns the id when it exists in the bot list", () => {
|
||||||
|
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
|
||||||
|
});
|
||||||
|
it("clears (null) when the requested id is not a known bot", () => {
|
||||||
|
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
|
||||||
|
* effective scope. Returns the id if it exists, else null (graceful clear:
|
||||||
|
* a stale/forbidden id never locks the UI). */
|
||||||
|
export function resolveScopedBot(
|
||||||
|
requestedId: string | null | undefined,
|
||||||
|
knownBotIds: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
if (!requestedId) return null;
|
||||||
|
return knownBotIds.includes(requestedId) ? requestedId : null;
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
const bot = store.bots.find((b) => b.id === botId);
|
const bot = store.bots.find((b) => b.id === botId);
|
||||||
if (bot) {
|
if (bot) {
|
||||||
store.setActiveBotId(botId);
|
store.setScope(botId);
|
||||||
router.replace('/');
|
router.replace({ path: '/', query: { bot: botId } });
|
||||||
} else {
|
} else {
|
||||||
notFound.value = true;
|
notFound.value = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-16
@@ -21,7 +21,7 @@
|
|||||||
<!-- 私人FM -->
|
<!-- 私人FM -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">私人FM</h2>
|
<h2 class="section-title">私人FM</h2>
|
||||||
<div class="fm-card hover-scale" @click="playFm">
|
<div class="fm-card hover-scale" @click="playFm('netease')">
|
||||||
<div class="fm-icon-wrapper">
|
<div class="fm-icon-wrapper">
|
||||||
<Icon icon="mdi:radio" class="fm-icon" />
|
<Icon icon="mdi:radio" class="fm-icon" />
|
||||||
</div>
|
</div>
|
||||||
@@ -31,6 +31,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||||
|
<div class="fm-icon-wrapper qq">
|
||||||
|
<Icon icon="mdi:radar" class="fm-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="fm-info">
|
||||||
|
<div class="fm-title">QQ音乐雷达</div>
|
||||||
|
<div class="fm-desc">猜你喜欢 / 雷达推荐歌曲流</div>
|
||||||
|
</div>
|
||||||
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 每日推荐 -->
|
<!-- 每日推荐 -->
|
||||||
@@ -72,6 +82,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 我的收藏 -->
|
||||||
|
<section class="section" v-if="store.favoritedPlaylists.length > 0">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
|
||||||
|
我的收藏
|
||||||
|
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
|
||||||
|
</h2>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<RouterLink
|
||||||
|
v-for="fav in store.favoritedPlaylists"
|
||||||
|
:key="fav.id"
|
||||||
|
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
|
||||||
|
class="playlist-card hover-scale"
|
||||||
|
>
|
||||||
|
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="playlist-name">{{ fav.name }}</div>
|
||||||
|
<div class="playlist-count">{{ fav.songCount }} 首</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 我的歌单 -->
|
<!-- 我的歌单 -->
|
||||||
<section class="section" v-if="userAvailable.length > 0">
|
<section class="section" v-if="userAvailable.length > 0">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
@@ -125,9 +156,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import { usePlayerStore, type Source } from '../stores/player.js';
|
||||||
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
|
||||||
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
|
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
|
||||||
import CoverArt from '../components/CoverArt.vue';
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
import SourceTabs from '../components/SourceTabs.vue';
|
import SourceTabs from '../components/SourceTabs.vue';
|
||||||
@@ -180,19 +211,8 @@ const visibleUserPlaylists = computed(() =>
|
|||||||
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
|
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
|
||||||
);
|
);
|
||||||
|
|
||||||
async function playFm() {
|
async function playFm(platform: Source) {
|
||||||
try {
|
await store.startFm(platform);
|
||||||
const res = await axios.get('/api/music/personal/fm');
|
|
||||||
const songs: Song[] = res.data.songs;
|
|
||||||
if (songs.length > 0) {
|
|
||||||
await store.play(songs[0].name, songs[0].platform);
|
|
||||||
for (let i = 1; i < songs.length; i++) {
|
|
||||||
await store.addToQueue(songs[i].name, songs[i].platform);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -318,6 +338,7 @@ onMounted(() => {
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
@@ -333,6 +354,10 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.qq {
|
||||||
|
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fm-icon {
|
.fm-icon {
|
||||||
|
|||||||
@@ -2,6 +2,27 @@
|
|||||||
<div class="library-page">
|
<div class="library-page">
|
||||||
<h1 class="page-title">音乐库</h1>
|
<h1 class="page-title">音乐库</h1>
|
||||||
|
|
||||||
|
<!-- 我的收藏 -->
|
||||||
|
<section class="section" v-if="store.favoritedPlaylists.length > 0">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
|
||||||
|
我的收藏
|
||||||
|
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
|
||||||
|
</h2>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<RouterLink
|
||||||
|
v-for="fav in store.favoritedPlaylists"
|
||||||
|
:key="fav.id"
|
||||||
|
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
|
||||||
|
class="playlist-card hover-scale"
|
||||||
|
>
|
||||||
|
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="playlist-name">{{ fav.name }}</div>
|
||||||
|
<div class="playlist-count">{{ fav.songCount }} 首</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 我的歌单 -->
|
<!-- 我的歌单 -->
|
||||||
<section class="section" v-if="userAvailable.length > 0">
|
<section class="section" v-if="userAvailable.length > 0">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
@@ -51,6 +72,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
||||||
|
|||||||
@@ -16,10 +16,21 @@
|
|||||||
<div class="playlist-stats">
|
<div class="playlist-stats">
|
||||||
{{ songs.length }} 首歌曲
|
{{ songs.length }} 首歌曲
|
||||||
</div>
|
</div>
|
||||||
<button class="play-all-btn" @click="playAll">
|
<div class="playlist-actions">
|
||||||
<Icon icon="mdi:play" />
|
<button class="play-all-btn" @click="playAll">
|
||||||
播放全部
|
<Icon icon="mdi:play" />
|
||||||
</button>
|
播放全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="kind === 'playlist'"
|
||||||
|
class="fav-btn"
|
||||||
|
:class="{ favorited }"
|
||||||
|
@click="toggleFavorite"
|
||||||
|
>
|
||||||
|
<Icon :icon="favorited ? 'mdi:heart' : 'mdi:heart-outline'" />
|
||||||
|
{{ favorited ? '已收藏' : '收藏' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,6 +80,7 @@ const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
|
|||||||
const playlist = ref<PlaylistDetail | null>(null);
|
const playlist = ref<PlaylistDetail | null>(null);
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const favorited = ref(false);
|
||||||
|
|
||||||
async function playAll() {
|
async function playAll() {
|
||||||
const id = route.params.id as string;
|
const id = route.params.id as string;
|
||||||
@@ -126,8 +138,35 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
songs.value = songList;
|
songs.value = songList;
|
||||||
|
|
||||||
|
// Check favorite status after songs are resolved
|
||||||
|
if (kind === 'playlist') {
|
||||||
|
favorited.value = store.isFavorited(id, platform);
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function toggleFavorite() {
|
||||||
|
const id = route.params.id as string;
|
||||||
|
const platform = (route.query.platform as string) || 'netease';
|
||||||
|
if (favorited.value) {
|
||||||
|
const fav = store.favoritedPlaylists.find((f) => f.playlistId === id && f.platform === platform);
|
||||||
|
if (fav) {
|
||||||
|
await store.removeFavorite(fav.id);
|
||||||
|
favorited.value = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await store.addFavorite({
|
||||||
|
platform,
|
||||||
|
playlistId: id,
|
||||||
|
name: playlist.value?.name ?? '未知歌单',
|
||||||
|
coverUrl: playlist.value?.coverUrl ?? '',
|
||||||
|
songCount: songs.value.length,
|
||||||
|
});
|
||||||
|
favorited.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -193,6 +232,43 @@ onMounted(async () => {
|
|||||||
&:active { transform: scale(0.96); }
|
&:active { transform: scale(0.96); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.favorited {
|
||||||
|
color: #e74c3c;
|
||||||
|
border-color: #e74c3c;
|
||||||
|
background: rgba(231, 76, 60, 0.08);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(231, 76, 60, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.song-list {
|
.song-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -92,6 +92,13 @@
|
|||||||
class="card hover-scale"
|
class="card hover-scale"
|
||||||
>
|
>
|
||||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<button
|
||||||
|
class="fav-badge"
|
||||||
|
:class="{ favorited: isFav(pl) }"
|
||||||
|
@click.prevent.stop="toggleFavPlaylist(pl)"
|
||||||
|
>
|
||||||
|
<Icon :icon="isFav(pl) ? 'mdi:heart' : 'mdi:heart-outline'" />
|
||||||
|
</button>
|
||||||
<div class="card-name">
|
<div class="card-name">
|
||||||
{{ pl.name }}
|
{{ pl.name }}
|
||||||
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
|
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
|
||||||
@@ -179,6 +186,25 @@ watch(selectedSource, (src) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isFav(pl: { id: string; platform: string }): boolean {
|
||||||
|
return store.isFavorited(pl.id, pl.platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFavPlaylist(pl: { id: string; platform: string; name: string; coverUrl: string; songCount?: number }) {
|
||||||
|
if (isFav(pl)) {
|
||||||
|
const fav = store.favoritedPlaylists.find((f) => f.playlistId === pl.id && f.platform === pl.platform);
|
||||||
|
if (fav) await store.removeFavorite(fav.id);
|
||||||
|
} else {
|
||||||
|
await store.addFavorite({
|
||||||
|
platform: pl.platform,
|
||||||
|
playlistId: pl.id,
|
||||||
|
name: pl.name,
|
||||||
|
coverUrl: pl.coverUrl,
|
||||||
|
songCount: pl.songCount ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
if (!query.value.trim()) return;
|
if (!query.value.trim()) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -356,6 +382,7 @@ onMounted(() => {
|
|||||||
gap: 16px 28px;
|
gap: 16px 28px;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -394,4 +421,39 @@ onMounted(() => {
|
|||||||
background: var(--brand-youtube-12);
|
background: var(--brand-youtube-12);
|
||||||
color: var(--brand-youtube);
|
color: var(--brand-youtube);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.card:hover & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.favorited {
|
||||||
|
color: #e74c3c;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #e74c3c;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -433,6 +433,18 @@
|
|||||||
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="profile-toggle behavior-toggle">
|
||||||
|
<div class="profile-toggle-text">
|
||||||
|
<div class="profile-toggle-label">频道无人时自动暂停播放</div>
|
||||||
|
<div class="profile-toggle-hint">机器人所在频道没有其他人时自动暂停,有人加入后可继续播放</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="autoPauseOnEmpty"
|
||||||
|
type="checkbox"
|
||||||
|
class="profile-toggle-switch"
|
||||||
|
@change="saveAutoPause"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bot Profile (TeamSpeak Behavior) -->
|
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||||
@@ -943,11 +955,13 @@ async function savePrefix() {
|
|||||||
|
|
||||||
// Idle timeout
|
// Idle timeout
|
||||||
const idleTimeout = ref(0);
|
const idleTimeout = ref(0);
|
||||||
|
const autoPauseOnEmpty = ref(true);
|
||||||
|
|
||||||
async function loadIdleTimeout() {
|
async function loadIdleTimeout() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/bot/settings');
|
const res = await axios.get('/api/bot/settings');
|
||||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||||
|
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? true;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,6 +971,12 @@ async function saveIdleTimeout() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAutoPause() {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/bot/settings', { autoPauseOnEmpty: autoPauseOnEmpty.value });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bot Profile config ---
|
// --- Bot Profile config ---
|
||||||
interface ProfileConfig {
|
interface ProfileConfig {
|
||||||
avatarEnabled: boolean;
|
avatarEnabled: boolean;
|
||||||
@@ -1939,6 +1959,12 @@ onUnmounted(() => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone toggle inside 行为设置 (not part of a bordered list)
|
||||||
|
.behavior-toggle {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.profile-bot-header {
|
.profile-bot-header {
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
|
|||||||
Reference in new issue
Block a user