mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b3ba0075 | ||
|
|
3abb468cca | ||
|
|
c8daa14219 | ||
|
|
81cd8a2bec | ||
|
|
35210cf570 |
No files matched your search
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.daily-card {
|
.daily-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-name {
|
.daily-name {
|
||||||
|
|||||||
Reference in new issue
Block a user