From be438db8d0952fb2851070460bc44ad03d1bf37b Mon Sep 17 00:00:00 2001 From: Nxssie Date: Sun, 5 Jul 2026 15:47:34 +0100 Subject: [PATCH 1/4] feat(db): add skip_votes table - separate skip intent from upvotes: a popular song (many upvotes) was easier to skip under the old model, which is inverted - unique (song_id, user_id) enforces one skip-vote per user per song --- packages/server/drizzle/0009_windy_speed.sql | 10 + .../server/drizzle/meta/0009_snapshot.json | 482 ++++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 22 + 4 files changed, 521 insertions(+) create mode 100644 packages/server/drizzle/0009_windy_speed.sql create mode 100644 packages/server/drizzle/meta/0009_snapshot.json diff --git a/packages/server/drizzle/0009_windy_speed.sql b/packages/server/drizzle/0009_windy_speed.sql new file mode 100644 index 0000000..308e6b5 --- /dev/null +++ b/packages/server/drizzle/0009_windy_speed.sql @@ -0,0 +1,10 @@ +CREATE TABLE `skip_votes` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `song_id` integer NOT NULL, + `user_id` text NOT NULL, + `created_at` integer, + FOREIGN KEY (`song_id`) REFERENCES `songs`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `skip_votes_song_id_user_id_unique` ON `skip_votes` (`song_id`,`user_id`); \ No newline at end of file diff --git a/packages/server/drizzle/meta/0009_snapshot.json b/packages/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..7ce0f69 --- /dev/null +++ b/packages/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,482 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "846182cd-b343-4f44-b200-2dd61a6bfa4f", + "prevId": "22e452b6-0514-45eb-b15c-fc71a225e18b", + "tables": { + "guilds": { + "name": "guilds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved": { + "name": "approved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_by_username": { + "name": "requested_by_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skip_votes": { + "name": "skip_votes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "song_id": { + "name": "song_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "skip_votes_song_id_user_id_unique": { + "name": "skip_votes_song_id_user_id_unique", + "columns": [ + "song_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "skip_votes_song_id_songs_id_fk": { + "name": "skip_votes_song_id_songs_id_fk", + "tableFrom": "skip_votes", + "tableTo": "songs", + "columnsFrom": [ + "song_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "skip_votes_user_id_users_id_fk": { + "name": "skip_votes_user_id_users_id_fk", + "tableFrom": "skip_votes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "songs": { + "name": "songs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_id": { + "name": "video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'youtube'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploader": { + "name": "uploader", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "votes": { + "name": "votes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "played": { + "name": "played", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "playlist_id": { + "name": "playlist_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "playlist_title": { + "name": "playlist_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "songs_room_votes_created_idx": { + "name": "songs_room_votes_created_idx", + "columns": [ + "room_id", + "\"votes\" desc", + "created_at" + ], + "isUnique": false + }, + "songs_room_played_idx": { + "name": "songs_room_played_idx", + "columns": [ + "room_id", + "played" + ], + "isUnique": false + } + }, + "foreignKeys": { + "songs_room_id_rooms_id_fk": { + "name": "songs_room_id_rooms_id_fk", + "tableFrom": "songs", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "votes": { + "name": "votes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "song_id": { + "name": "song_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "votes_user_id_idx": { + "name": "votes_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "votes_song_id_user_id_unique": { + "name": "votes_song_id_user_id_unique", + "columns": [ + "song_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "votes_song_id_songs_id_fk": { + "name": "votes_song_id_songs_id_fk", + "tableFrom": "votes", + "tableTo": "songs", + "columnsFrom": [ + "song_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "votes_user_id_users_id_fk": { + "name": "votes_user_id_users_id_fk", + "tableFrom": "votes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "songs_room_votes_created_idx": { + "columns": { + "\"votes\" desc": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index 98da6c1..c571d8d 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1782983251589, "tag": "0008_steep_sharon_carter", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1783202679939, + "tag": "0009_windy_speed", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 71bf2b5..d480f77 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -68,6 +68,27 @@ export const votes = sqliteTable( ] ); +// Skip-votes are separate from upvotes so the two intents don't collide: +// an upvote means "I want this to play", a skip-vote means "skip it now". +// Reusing the upvote tally as skip authority (the old model) was inverted — +// a popular song (many upvotes) was *easier* to skip, which is backwards. +export const skipVotes = sqliteTable( + "skip_votes", + { + id: integer("id").primaryKey({ autoIncrement: true }), + songId: integer("song_id") + .notNull() + .references(() => songs.id), + userId: text("user_id") + .notNull() + .references(() => users.id), + createdAt: integer("created_at").$defaultFn(() => Math.floor(Date.now() / 1000)), + }, + (t) => [ + unique().on(t.songId, t.userId), + ] +); + export const guilds = sqliteTable("guilds", { id: text("id").primaryKey(), // Discord guild ID name: text("name"), @@ -83,3 +104,4 @@ export type Room = typeof rooms.$inferSelect; export type Song = typeof songs.$inferSelect; export type Vote = typeof votes.$inferSelect; export type GuildRecord = typeof guilds.$inferSelect; +export type SkipVote = typeof skipVotes.$inferSelect; From 34f2f56e7df26886287bb3cbf7618b92b1d4591f Mon Sep 17 00:00:00 2001 From: Nxssie Date: Sun, 5 Jul 2026 15:48:53 +0100 Subject: [PATCH 2/4] fix(player): guard against re-picking the streaming track - playNextFromRoomInner could re-pick the currently-streaming song (still played=false until it finishes) and restart yt-dlp + ffmpeg from scratch --- packages/server/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7aa00e3..8c6efe3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -589,6 +589,12 @@ async function playNextFromRoom(roomId: string, guildId: string) { } async function playNextFromRoomInner(roomId: string, guildId: string) { + // Don't clobber a track that's already streaming. The current song stays + // `played = false` until it finishes, so without this guard we'd re-pick it + // and restart from scratch — re-spawning yt-dlp + ffmpeg. The Idle handler + // advances when the current track ends. + if (currentTracks.has(guildId)) return; + const allSongs = await db .select() .from(songs) From a7a2018cabd0c71854430bd666969bcffa040885 Mon Sep 17 00:00:00 2001 From: Nxssie Date: Sun, 5 Jul 2026 15:54:19 +0100 Subject: [PATCH 3/4] feat(skip): separate skip-votes from upvotes - new /skip-vote endpoint: registers a per-user skip-vote on the current song; auto-executes the skip once tally >= skipThreshold(roomPresence) - the adder can skip directly (owner bypass); double-voting returns 409 - /skip and /songs now report skipVotesCount + userSkipVote instead of reusing the upvote tally, which was inverted (popular songs skipped easier) - roomPresence counts Discord voice listeners (voicePresenceByRoom) plus web presence, so a minority web vote can't skip a song many hear in voice - cascade-delete skip_votes on song/room/playlist/GC cleanup - web: skip button shows N/threshold, a check once voted, owner skips directly; pendingSongs excludes the streaming song by id, not slice(1) --- packages/server/src/index.ts | 130 ++++++++++++++++++++++++++++++-- packages/web/src/pages/Room.tsx | 51 +++++++++---- 2 files changed, 158 insertions(+), 23 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8c6efe3..bbb5b5b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -27,7 +27,7 @@ import { type VoiceConnection, } from "@discordjs/voice"; import { db } from "./db"; -import { users, rooms, songs, votes, guilds, type User } from "./db/schema"; +import { users, rooms, songs, votes, skipVotes, guilds, type User } from "./db/schema"; import { eq, desc, and, inArray, lt, sql } from "drizzle-orm"; import { commands } from "./commands"; import { extractVideoId, isPlaylistUrl } from "./lib/youtube"; @@ -492,6 +492,7 @@ function setupPlayer(guildId: string): AudioPlayer { if (track) { track.cleanup(); await db.update(songs).set({ played: true }).where(eq(songs.id, track.songId)).run(); + await db.delete(skipVotes).where(eq(skipVotes.songId, track.songId)).run(); currentTracks.delete(guildId); } @@ -993,7 +994,7 @@ app.get("/api/rooms/:id/songs", async (c) => { userVotes = userVoteRecords.map((v) => v.songId); } - const presentCount = [...userCurrentRoom.values()].filter((r) => r === id).length; + const presentCount = roomPresence(id); const guildId = [...guildRoomMap.entries()].find(([, r]) => r === id)?.[0]; const track = guildId ? currentTracks.get(guildId) : null; @@ -1005,7 +1006,26 @@ app.get("/api/rooms/:id/songs", async (c) => { // to the vote-order heuristic in that case. const currentSongId = track?.songId ?? null; - return c.json({ songs: roomSongs, userVotes, presentCount, currentSongStartedAt, currentSongId }); + // The skip-vote tally must anchor to the same song the /skip-vote endpoint + // acts on: the streaming track when the bot is connected, else the + // vote-order first-unplayed fallback. currentSongId stays null when no bot + // is streaming (so the frontend can show "not connected"), but the tally + // uses the effective song so the counter is live even before the bot joins. + const fallback = roomSongs.find((s) => !s.played); + const effectiveSongId = track?.songId ?? fallback?.id ?? null; + let skipVotesCount = 0; + let userSkipVote = false; + if (effectiveSongId) { + const skipRows = await db + .select({ userId: skipVotes.userId }) + .from(skipVotes) + .where(eq(skipVotes.songId, effectiveSongId)) + .all(); + skipVotesCount = skipRows.length; + if (user) userSkipVote = skipRows.some((r) => r.userId === user.id); + } + + return c.json({ songs: roomSongs, userVotes, presentCount, currentSongStartedAt, currentSongId, skipVotesCount, userSkipVote }); }); app.post("/api/rooms/:id/songs", async (c) => { @@ -1123,7 +1143,7 @@ app.post("/api/rooms/:id/skip", async (c) => { const user = await getUser(c); if (!user) return c.json({ error: "Login required" }, 401); - const presentCount = [...userCurrentRoom.values()].filter((r) => r === id).length; + const presentCount = roomPresence(id); const threshold = skipThreshold(presentCount); const guildId = [...guildRoomMap.entries()].find(([, r]) => r === id)?.[0]; @@ -1146,21 +1166,78 @@ app.post("/api/rooms/:id/skip", async (c) => { if (!current) return c.json({ error: "Nothing playing" }, 404); const isOwner = current.addedByUserId === user.id; - if (!isOwner && current.votes < threshold) return c.json({ error: "Not enough votes", votes: current.votes, threshold }, 403); + const skipVotesCount = (await db.select({ c: sql`count(*)` }).from(skipVotes).where(eq(skipVotes.songId, current.id)).get())?.c ?? 0; + if (!isOwner && skipVotesCount < threshold) return c.json({ error: "Not enough votes", skipVotes: skipVotesCount, threshold }, 403); // Mark as played before stopping the player so the DB is consistent when the // frontend refetches. The Idle handler will try to mark it again — harmless. await db.update(songs).set({ played: true }).where(eq(songs.id, current.id)).run(); + await db.delete(skipVotes).where(eq(skipVotes.songId, current.id)).run(); if (guildId) { recentSkip.add(guildId); players.get(guildId)?.stop(); } - console.log(`⏭️ Song "${current.title}" skipped in room ${id} by ${user.username} (${current.votes}/${threshold} votes)`); + console.log(`⏭️ Song "${current.title}" skipped in room ${id} by ${user.username} (${skipVotesCount}/${threshold} skip-votes)`); return c.json({ success: true }); }); +// Register a skip-vote for the current song. The adder can skip directly +// (owner bypass); otherwise the vote is tallied and the skip auto-executes +// once skipVotes >= skipThreshold(roomPresence). This replaces the old model +// where upvotes doubled as skip authority — inverted, since a popular song +// (many upvotes) was easier to skip than an unpopular one. +app.post("/api/rooms/:id/skip-vote", async (c) => { + const { id } = c.req.param(); + const user = await getUser(c); + if (!user) return c.json({ error: "Login required" }, 401); + + const guildId = [...guildRoomMap.entries()].find(([, r]) => r === id)?.[0]; + const track = guildId ? currentTracks.get(guildId) : null; + const current = track + ? await db.select().from(songs).where(eq(songs.id, track.songId)).get() + : await db + .select() + .from(songs) + .where(and(eq(songs.roomId, id), eq(songs.played, false))) + .orderBy(desc(songs.votes), songs.createdAt) + .get(); + + if (!current) return c.json({ error: "Nothing playing" }, 404); + + const threshold = skipThreshold(roomPresence(id)); + const isOwner = !!current.addedByUserId && current.addedByUserId === user.id; + + if (isOwner) { + await db.update(songs).set({ played: true }).where(eq(songs.id, current.id)).run(); + await db.delete(skipVotes).where(eq(skipVotes.songId, current.id)).run(); + if (guildId) { recentSkip.add(guildId); players.get(guildId)?.stop(); } + console.log(`⏭️ Song "${current.title}" skip-voted (owner) in room ${id} by ${user.username}`); + return c.json({ skipped: true }); + } + + const existing = await db + .select() + .from(skipVotes) + .where(and(eq(skipVotes.songId, current.id), eq(skipVotes.userId, user.id))) + .get(); + if (existing) return c.json({ error: "Already voted to skip" }, 409); + + await db.insert(skipVotes).values({ songId: current.id, userId: user.id }).run(); + const count = (await db.select({ c: sql`count(*)` }).from(skipVotes).where(eq(skipVotes.songId, current.id)).get())?.c ?? 0; + + if (count >= threshold) { + await db.update(songs).set({ played: true }).where(eq(songs.id, current.id)).run(); + await db.delete(skipVotes).where(eq(skipVotes.songId, current.id)).run(); + if (guildId) { recentSkip.add(guildId); players.get(guildId)?.stop(); } + console.log(`⏭️ Song "${current.title}" skipped by vote in room ${id} (${count}/${threshold})`); + return c.json({ skipped: true }); + } + + return c.json({ skipped: false, skipVotes: count, threshold }); +}); + // Skip all remaining songs in a playlist. Only the user who added the playlist // or an admin can do this — it's a bulk action that bypasses per-song voting. app.post("/api/rooms/:id/playlists/:playlistId/skip", async (c) => { @@ -1181,6 +1258,7 @@ app.post("/api/rooms/:id/playlists/:playlistId/skip", async (c) => { const ids = playlistSongs.map((s) => s.id); await db.update(songs).set({ played: true }).where(inArray(songs.id, ids)).run(); + await db.delete(skipVotes).where(inArray(skipVotes.songId, ids)).run(); // Stop the player if the current track belongs to this playlist. for (const [guildId, roomId] of guildRoomMap) { @@ -1218,6 +1296,7 @@ app.delete("/api/rooms/:id/songs/:songId", async (c) => { await db.transaction(async (tx) => { await tx.delete(votes).where(eq(votes.songId, Number(songId))); + await tx.delete(skipVotes).where(eq(skipVotes.songId, Number(songId))); await tx.delete(songs).where(eq(songs.id, Number(songId))); }); return c.json({ success: true }); @@ -1248,6 +1327,7 @@ app.delete("/api/rooms/:id", async (c) => { await db.transaction(async (tx) => { if (songIds.length > 0) { await tx.delete(votes).where(inArray(votes.songId, songIds)); + await tx.delete(skipVotes).where(inArray(skipVotes.songId, songIds)); await tx.delete(songs).where(eq(songs.roomId, id)); } await tx.delete(rooms).where(eq(rooms.id, id)); @@ -1435,11 +1515,15 @@ app.get("/api/admin/rooms", async (c) => { .all(); const countByRoom = new Map(counts.map((r) => [r.roomId, r])); - // Live presence (in-memory): users currently viewing each room. + // Live presence (in-memory): users currently viewing each room, plus + // Discord listeners in the bot's voice channel for guilds bound to the room. const presentByRoom = new Map(); for (const roomId of userCurrentRoom.values()) { presentByRoom.set(roomId, (presentByRoom.get(roomId) ?? 0) + 1); } + for (const [roomId, count] of voicePresenceByRoom()) { + presentByRoom.set(roomId, (presentByRoom.get(roomId) ?? 0) + count); + } // Resolve owner usernames in one query. const ownerIds = [ @@ -1784,6 +1868,7 @@ async function purgePlayedSongs() { const ids = old.map((s) => s.id); await db.transaction(async (tx) => { await tx.delete(votes).where(inArray(votes.songId, ids)); + await tx.delete(skipVotes).where(inArray(skipVotes.songId, ids)); await tx.delete(songs).where(inArray(songs.id, ids)); }); console.log(`🧹 GC: purged ${ids.length} old played song(s)`); @@ -1821,6 +1906,37 @@ function isAloneInVoice(guildId: string): boolean { return true; } +// Discord listeners in the bot's voice channel don't register web presence +// (userCurrentRoom is only populated via the browser), so without this the +// skip threshold would ignore them and a minority web vote could skip a song +// many people are listening to in voice. Counts non-bot members in the bot's +// channel across every guild bound to the room. +function voicePresenceByRoom(): Map { + const map = new Map(); + const botId = discord.user?.id; + for (const [guildId, roomId] of guildRoomMap) { + const conn = connections.get(guildId); + if (!conn) continue; + const channelId = conn.joinConfig.channelId; + if (!channelId) continue; + const guild = discord.guilds.cache.get(guildId); + if (!guild) continue; + for (const vs of guild.voiceStates.cache.values()) { + if (vs.channelId === channelId && vs.id !== botId) { + map.set(roomId, (map.get(roomId) ?? 0) + 1); + } + } + } + return map; +} + +// Total listeners in a room: web presence + Discord voice presence. +function roomPresence(roomId: string): number { + const web = [...userCurrentRoom.values()].filter((r) => r === roomId).length; + const voice = voicePresenceByRoom().get(roomId) ?? 0; + return web + voice; +} + async function hasUnplayedSongs(roomId: string): Promise { const row = await db .select({ c: sql`count(*)` }) diff --git a/packages/web/src/pages/Room.tsx b/packages/web/src/pages/Room.tsx index 01fe974..36bba07 100644 --- a/packages/web/src/pages/Room.tsx +++ b/packages/web/src/pages/Room.tsx @@ -81,6 +81,12 @@ export default function Room() { // song's votes overtake the one already playing). Null when nothing is // actively streaming (bot not connected), so we fall back to vote order. const [currentSongId, setCurrentSongId] = useState(null); + // Skip-vote tally for the current song + whether this user has voted. + // Separate from upvotes so "I want this to play" and "skip it now" don't + // collide (the old model reused upvotes as skip authority, which was + // inverted — a popular song was easier to skip). + const [skipVotesCount, setSkipVotesCount] = useState(0); + const [userSkipVote, setUserSkipVote] = useState(false); // Signatures of the last applied payload, so an unchanged 4s poll doesn't // rebuild a new array and re-render the whole queue. @@ -107,6 +113,8 @@ export default function Room() { setPresentCount(data.presentCount ?? 1); setCurrentSongStartedAt(data.currentSongStartedAt ?? null); setCurrentSongId(data.currentSongId ?? null); + setSkipVotesCount(data.skipVotesCount ?? 0); + setUserSkipVote(!!data.userSkipVote); } catch { setError("ERR_01: fetch_failed;"); } finally { @@ -337,10 +345,10 @@ export default function Room() { } }; - const skipSong = async () => { - if (!id) return; + const voteSkip = async () => { + if (!id || !user || !currentSong) return; try { - const res = await fetch(`/api/rooms/${id}/skip`, { + const res = await fetch(`/api/rooms/${id}/skip-vote`, { method: "POST", credentials: "include", }); @@ -348,21 +356,32 @@ export default function Room() { await fetchSongs(); } else { const data = await res.json().catch(() => ({})); - setError(data.error || "ERR_07: skip_failed;"); + setError(data.error || "ERR_07: skip_vote_failed;"); setTimeout(() => setError(null), 3000); } } catch { - setError("ERR_07: skip_failed;"); + setError("ERR_07: skip_vote_failed;"); setTimeout(() => setError(null), 3000); } }; - const pendingSongs = useMemo(() => songs.filter((s) => !s.played).slice(1), [songs]); + // Exclude the actually-streaming song (currentSong), not just "the first + // unplayed". Once a pending song overtakes the playing one in votes, the + // playing song is no longer the first unplayed — slicing the first would + // then hide the real next-to-play song AND render the current one twice + // (here and in the ON_AIR highlight). currentSong already encodes the + // correct anchor (currentSongId when the bot is connected, else the + // vote-order fallback), so excluding its id is consistent in both cases. + const pendingSongs = useMemo( + () => songs.filter((s) => !s.played && s.id !== currentSong?.id), + [songs, currentSong] + ); const playedSongs = useMemo(() => songs.filter((s) => s.played), [songs]); const skipThreshold = Math.max(1, Math.ceil(presentCount / 2)); - const canSkip = !!currentSong && ( - currentSong.votes >= skipThreshold || user?.id === currentSong.addedByUserId - ); + const isCurrentOwner = !!user && !!currentSong && currentSong.addedByUserId === user.id; + // The adder can skip directly; anyone else votes, and the skip auto-executes + // once skipVotes >= threshold (handled server-side in /skip-vote). + const canSkipVote = !!user && !!currentSong && (isCurrentOwner || !userSkipVote); // Group pending songs: consecutive songs sharing a playlistId are merged into // one entry; individual songs are their own entry. @@ -772,20 +791,20 @@ export default function Room() { /> _playing; - {user && ( + {user && currentSong && ( )} From ee5942915ad5984756735d4724a041c5e5a6ecfc Mon Sep 17 00:00:00 2001 From: Nxssie Date: Sun, 5 Jul 2026 15:57:56 +0100 Subject: [PATCH 4/4] feat(bot): add /room command and align /skip /stop /reset with web gates - /room reports the bound room id, owner, bot status, now-playing, and a browser link so server members can find and join the same room - /skip now enforces the same skip-vote gate as the web (adder bypass or skipThreshold votes), instead of a one-click bypass - /stop and /reset are room-wide actions, now gated behind Manage Server instead of any member --- README.md | 1 + packages/server/src/commands.ts | 3 + packages/server/src/index.ts | 115 ++++++++++++++++++++++++++++++-- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e3ccc50..dc2e092 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ YouTube, SoundCloud, Mixcloud, or Twitch URL: - `/skip` — Skip the current song - `/queue` — Show the current queue - `/reset` — Mark all songs as playable again +- `/room` — Show which room this server is currently playing from ## Deploy (Coolify + Cloudflare Tunnel) diff --git a/packages/server/src/commands.ts b/packages/server/src/commands.ts index c6793a4..33fc9f9 100644 --- a/packages/server/src/commands.ts +++ b/packages/server/src/commands.ts @@ -25,4 +25,7 @@ export const commands = [ new SlashCommandBuilder() .setName("reset") .setDescription("Reset all songs to be playable again"), + new SlashCommandBuilder() + .setName("room") + .setDescription("Show which room this server is currently playing from"), ].map((command) => command.toJSON()); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index bbb5b5b..5242b96 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1767,17 +1767,54 @@ discord.on(Events.InteractionCreate, async (interaction) => { if (interaction.commandName === "stop") { if (!guildId) return; + // Disconnecting the bot affects everyone in the channel — gate to + // server moderators rather than any member. + if (!(interaction.memberPermissions?.has(PermissionsBitField.Flags.ManageGuild) ?? false)) { + await interaction.reply({ content: "🚫 You need **Manage Server** permission to stop the bot.", ephemeral: true }); + return; + } teardownGuild(guildId); await interaction.reply("⏹️ Stopped"); } if (interaction.commandName === "skip") { if (!guildId) return; - const player = players.get(guildId); - if (player) { - recentSkip.add(guildId); - player.stop(); + const roomId = guildRoomMap.get(guildId) || guildId; + + // Mirror the web skip gate (POST /api/rooms/:id/skip): the adder can + // always skip, otherwise the song needs skipThreshold upvotes from the + // room's present listeners (web + Discord voice). Without this /skip + // was a one-click bypass of the vote system the web enforces. + const track = currentTracks.get(guildId); + const current = track + ? await db.select().from(songs).where(eq(songs.id, track.songId)).get() + : await db + .select() + .from(songs) + .where(and(eq(songs.roomId, roomId), eq(songs.played, false))) + .orderBy(desc(songs.votes), songs.createdAt) + .get(); + + if (!current) { + await interaction.reply({ content: "📭 Nothing is playing", ephemeral: true }); + return; } + + const threshold = skipThreshold(roomPresence(roomId)); + const isOwner = !!current.addedByUserId && current.addedByUserId === interaction.user.id; + const skipVotesCount = (await db.select({ c: sql`count(*)` }).from(skipVotes).where(eq(skipVotes.songId, current.id)).get())?.c ?? 0; + if (!isOwner && skipVotesCount < threshold) { + await interaction.reply({ + content: `🗳️ Not enough votes to skip **${current.title || current.videoId}** — ${skipVotesCount}/${threshold}. The person who added it can skip anytime.`, + ephemeral: true, + }); + return; + } + + await db.update(songs).set({ played: true }).where(eq(songs.id, current.id)).run(); + await db.delete(skipVotes).where(eq(skipVotes.songId, current.id)).run(); + recentSkip.add(guildId); + players.get(guildId)?.stop(); await interaction.reply("⏭️ Skipped"); } @@ -1810,11 +1847,81 @@ discord.on(Events.InteractionCreate, async (interaction) => { if (interaction.commandName === "reset") { if (!guildId) return; + // Resetting the whole queue is a room-wide action — gate to moderators. + if (!(interaction.memberPermissions?.has(PermissionsBitField.Flags.ManageGuild) ?? false)) { + await interaction.reply({ content: "🚫 You need **Manage Server** permission to reset the queue.", ephemeral: true }); + return; + } const roomId = guildRoomMap.get(guildId) || guildId; await db.update(songs).set({ played: false }).where(eq(songs.roomId, roomId)).run(); await interaction.reply("🔄 Queue reset — all songs are now playable"); } + + // Report which room this guild's playback is fed from (the guildRoomMap + // entry), so people in the server can see and join the same room in the + // browser. Falls back to the guild's own id when nothing has been bound + // yet — but only after /play or /listen sets the map, so a missing entry + // means the bot has never been started here. + if (interaction.commandName === "room") { + if (!guildId) return; + const roomId = guildRoomMap.get(guildId); + if (!roomId) { + await interaction.reply("📭 No room bound to this server yet. Use `/play` or `/listen` to start."); + return; + } + + const room = await db.select().from(rooms).where(eq(rooms.id, roomId)).get(); + const connected = connections.has(guildId); + const isDefault = roomId === guildId; + + const counts = await db + .select({ + total: sql`count(*)`, + pending: sql`sum(case when coalesce(${songs.played}, 0) = 0 then 1 else 0 end)`, + }) + .from(songs) + .where(eq(songs.roomId, roomId)) + .get(); + const total = Number(counts?.total ?? 0); + const pending = Number(counts?.pending ?? 0); + + let ownerLine: string | null = null; + if (room?.createdBy) { + const owner = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, room.createdBy)) + .get(); + if (owner?.username) ownerLine = `• Owner: ${owner.username}`; + } + + let nowLine: string | null = null; + const track = currentTracks.get(guildId); + if (track) { + const cur = await db + .select({ title: songs.title, videoId: songs.videoId }) + .from(songs) + .where(eq(songs.id, track.songId)) + .get(); + nowLine = `• Now playing: **${cur?.title || cur?.videoId || `#${track.songId}`}**`; + } + + const header = isDefault + ? "🎵 This server is playing from its **default queue**" + : `🎵 This server is playing from room **${roomId}**`; + const lines = [ + header, + `• Room ID: \`${roomId}\``, + `• Open in browser: ${FRONTEND_URL}/room/${roomId}`, + ownerLine, + `• Bot: ${connected ? "🔊 Connected" : "💤 Not connected"}`, + nowLine, + `• Songs: ${pending} pending / ${total} total`, + ].filter(Boolean); + + await interaction.reply(lines.join("\n")); + } } catch (err) { console.error(`Interaction '${interaction.commandName}' failed:`, err); try {