Compare commits

...
Author SHA1 Message Date
saopig1andClaude Opus 4.8 e9b3ba0075 feat(queue): shuffle-bag random modes so every song plays before repeating
随机循环 (rloop) used true random-with-replacement, so some songs repeated constantly while others were starved (issue #70). Both random modes now draw from a shuffle bag: every song plays exactly once per cycle in random order. They differ only at cycle end — 随机 (random) stops, 随机循环 (rloop) reshuffles and continues, excluding the just-played song from the first pick of the new cycle to avoid a back-to-back repeat across the boundary. Songs added mid-cycle stay eligible within the current cycle.

随机's visible behavior is unchanged (it already avoided in-cycle repeats); the two branches now share one selection path. Adds shuffle-bag tests (per-cycle permutation, even distribution, no cross-boundary repeat, mid-cycle add).

Closes #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:08:40 +08:00
TIANYAO ZHANG 3abb468cca Merge pull request #75 from ZHANGTIANYAO1/fix/ui-overflow-and-textarea-resize
fix(web): long artist + B站 card grid + B站 image referer
2026-05-27 20:10:21 +08:00
saopig1andClaude Opus 4.7 c8daa14219 fix(web): set no-referrer at document level so B站 cover thumbnails load
Bilibili's CDN (i*.hdslb.com) returns 403 with `x-error-info:
RefererWhite` for image requests whose Referer is not on their
whitelist. `CoverArt.vue` already sets `referrerpolicy="no-referrer"`
on its `<img>` tag, BUT the `.cover-shadow` div renders the same URL
as a CSS `background-image`, which ignores the img attribute and uses
the document default policy (`strict-origin-when-cross-origin` in
modern Firefox/Chrome) — that sends `Referer: http://localhost:3000/`
and triggers the block.

Setting `<meta name="referrer" content="no-referrer">` in index.html
applies no-referrer site-wide: covers <img> tags, CSS background-image
fetches, and anywhere else CDNs check referer. Doesn't affect our
/api/* CSRF middleware because that uses Origin (still sent by the
browser), not Referer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:07:18 +08:00
saopig1andClaude Opus 4.7 81cd8a2bec fix(web): revert textarea + actual culprit was B站热门 card grid
Previous commit misidentified the second bug. Reverting the
Settings.vue `resize: vertical` → `resize: none` change — that
wasn't the issue.

Real fix: `.daily-card` (used by B站热门 and 每日推荐 sections in
Home.vue) is a CSS Grid cell with default `min-width: auto`, which
refuses to shrink below its content. A long Bilibili video title
inside `.daily-name` expanded the cell past its 1fr column, breaking
the 6-column grid and creating empty/black space on the right. The
existing `text-overflow: ellipsis` on `.daily-name` couldn't engage.

Adding `min-width: 0` to `.daily-card` lets the cell shrink to the
1fr grid track size, and the ellipsis truncation now works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:20:46 +08:00
saopig1andClaude Opus 4.7 35210cf570 fix(web): long artist name overflow + textarea resize artifact
- Player.vue: wrap artist text in a span with ellipsis. The previous
  text node sat directly inside the flex `.song-artist` container with
  no overflow handling, so a long author name expanded the container
  past its 240px parent and broke the bottom Player bar layout. Also
  add `min-width: 0 + overflow: hidden` to `.song-info` and
  `.song-artist`, and a `:title` attribute for the full text on hover.

- Settings.vue: change `resize: vertical` on the cookie textareas
  to `resize: none`. The browser's resize grip rendered as a stray
  black triangle at the bottom-right corner in dark theme, and
  dragging it caused visual artifacts on the right edge. The
  textareas keep their `rows="3"` default height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:14:26 +08:00
TIANYAO ZHANG d2bad58aa8 Merge pull request #74 from ZHANGTIANYAO1/feat/webui-auth
Add WebUI authentication: multi-user, roles, audit log
2026-05-27 16:46:03 +08:00
TIANYAO ZHANG a2982948a7 Merge pull request #71 from EvolvedGhost/main
fix(player): 歌曲结尾后持续卡死不切换下一首歌
2026-05-25 18:56:14 +08:00
EvolvedGhost b7e1f9f30b fix(player): add force trackEnd when pcmBuffer less than PCM_FRAME_BYTES 2026-05-23 22:08:52 +08:00
7 changed files with 185 additions and 27 deletions

No files matched your search

+55 -3
View File
@@ -136,6 +136,9 @@ export class AudioPlayer extends EventEmitter {
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
private downloader: ChildProcess | null = null; private downloader: ChildProcess | null = null;
private currentTempDir: string | null = null; private currentTempDir: string | null = null;
private emptyFrameAttempts = 0;
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
private currentSongDuration = 0; // 当前歌曲总时长(秒)
constructor(logger: Logger) { constructor(logger: Logger) {
super(); super();
@@ -143,7 +146,7 @@ export class AudioPlayer extends EventEmitter {
this.logger = logger; this.logger = logger;
} }
play(url: string, seekSeconds = 0): void { play(url: string, seekSeconds = 0, songDuration = 0): void {
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 ( // 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
this.stop(); this.stop();
@@ -154,6 +157,8 @@ export class AudioPlayer extends EventEmitter {
this.healthyFrames = 0; this.healthyFrames = 0;
this.ffmpegPaused = false; this.ffmpegPaused = false;
this.spawnFailed = false; this.spawnFailed = false;
this.emptyFrameAttempts = 0;
this.currentSongDuration = songDuration;
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) { if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached"); this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
@@ -183,7 +188,7 @@ export class AudioPlayer extends EventEmitter {
if (this.sessionId !== currentSessionId) { if (this.sessionId !== currentSessionId) {
return; return;
} }
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]); this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) { if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause(); this.ffmpeg.stdout.pause();
@@ -420,6 +425,49 @@ export class AudioPlayer extends EventEmitter {
if (this.state === "playing") this.sendNextFrame(); if (this.state === "playing") this.sendNextFrame();
else if (this.state === "paused") this.nextFrameTime = performance.now(); else if (this.state === "paused") this.nextFrameTime = performance.now();
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
const elapsed = this.getElapsed();
const isNearEnd = this.currentSongDuration > 0
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
: true; // 未知时长时保守处理
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.emptyFrameAttempts++;
// 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束
if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) {
this.logger.info({
sessionId: this.sessionId,
emptyAttempts: this.emptyFrameAttempts,
bufferSize: this.pcmBuffer.length,
elapsed: Math.round(elapsed),
duration: this.currentSongDuration,
remaining: Math.round(this.currentSongDuration - elapsed)
}, "FFmpeg stopped outputting data near end, ending track");
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
return;
}
} else {
// 成功获取数据或FFmpeg已结束,重置计数器
this.emptyFrameAttempts = 0;
}
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) { if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false; this.frameLoopRunning = false;
if (this.state !== "idle") { if (this.state !== "idle") {
@@ -472,7 +520,11 @@ export class AudioPlayer extends EventEmitter {
} }
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; } getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); } seek(seconds: number): void {
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
this.play(this.currentUrl, seconds, this.currentSongDuration);
}
}
pause(): void { if (this.state === "playing") this.state = "paused"; } pause(): void { if (this.state === "playing") this.state = "paused"; }
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } } resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
resetFailures(): void { this.consecutiveFailures = 0; } resetFailures(): void { this.consecutiveFailures = 0; }
+80
View File
@@ -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
View File
@@ -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];
} }
} }
} }
+1 -1
View File
@@ -380,7 +380,7 @@ export class BotInstance extends EventEmitter {
return false; return false;
} }
song.url = url; song.url = url;
this.player.play(url); this.player.play(url, 0, song.duration);
this.database.addPlayHistory({ this.database.addPlayHistory({
botId: this.id, botId: this.id,
songId: song.id, songId: song.id,
+4
View File
@@ -3,6 +3,10 @@
<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.
Setting no-referrer at the document level covers <img> tags AND CSS background-image fetches.
Our own /api/* CSRF check uses Origin (not Referer), so this doesn't break auth. -->
<meta name="referrer" content="no-referrer">
<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
View File
@@ -27,10 +27,10 @@
<div class="player-left" @click="toggleLyrics"> <div class="player-left" @click="toggleLyrics">
<CoverArt :url="currentSong.coverUrl" :size="40" /> <CoverArt :url="currentSong.coverUrl" :size="40" />
<div class="song-info"> <div class="song-info">
<div class="song-name">{{ currentSong.name }}</div> <div class="song-name" :title="currentSong.name">{{ currentSong.name }}</div>
<div class="song-artist"> <div class="song-artist">
<span v-if="showBotBadge" class="bot-badge">{{ activeBot?.name }}</span> <span v-if="showBotBadge" class="bot-badge">{{ activeBot?.name }}</span>
{{ currentSong.artist }} <span class="artist-name" :title="currentSong.artist">{{ currentSong.artist }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -310,6 +310,8 @@ function cycleMode() {
.song-info { .song-info {
min-width: 0; min-width: 0;
flex: 1;
overflow: hidden;
} }
.song-name { .song-name {
@@ -326,6 +328,16 @@ function cycleMode() {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
min-width: 0;
overflow: hidden;
}
.artist-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
} }
.bot-badge { .bot-badge {
+1
View File
@@ -379,6 +379,7 @@ onMounted(() => {
.daily-card { .daily-card {
cursor: pointer; cursor: pointer;
min-width: 0;
} }
.daily-name { .daily-name {