From 341f2ea1494b69593b4cd4faf29011034c4fe89e Mon Sep 17 00:00:00 2001 From: Nxssie Date: Tue, 30 Jun 2026 22:20:59 +0100 Subject: [PATCH 1/2] feat(songs): add SoundCloud as a song source - detect source from pasted URL host (lib/sources.ts), search via scsearch/ytsearch with a source toggle, and add single tracks, YouTube playlists, and SoundCloud sets through one shared resolver - store source + thumbnail on songs (SoundCloud has no derivable thumbnail CDN pattern like YouTube's i.ytimg.com) - drop the embedded YouTube preview player for artwork + an open-in-source link, removing the react-youtube dependency - extend the Discord /play command to accept SoundCloud URLs too - guard double-submit on the add-song form --- bun.lock | 19 - .../drizzle/0007_thankful_kitty_pryde.sql | 2 + .../server/drizzle/meta/0007_snapshot.json | 348 ++++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/commands.ts | 2 +- packages/server/src/db/schema.ts | 5 + packages/server/src/index.ts | 212 ++++++++--- packages/server/src/lib/sources.test.ts | 32 ++ packages/server/src/lib/sources.ts | 36 ++ packages/web/package.json | 3 +- packages/web/src/components/SongItem.tsx | 2 + packages/web/src/pages/Room.tsx | 142 ++++--- 12 files changed, 698 insertions(+), 112 deletions(-) create mode 100644 packages/server/drizzle/0007_thankful_kitty_pryde.sql create mode 100644 packages/server/drizzle/meta/0007_snapshot.json create mode 100644 packages/server/src/lib/sources.test.ts create mode 100644 packages/server/src/lib/sources.ts diff --git a/bun.lock b/bun.lock index f1d56dc..f40a8de 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,6 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.0", - "react-youtube": "^10.1.0", }, "devDependencies": { "@tailwindcss/vite": "^4.3.1", @@ -507,14 +506,10 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "load-script": ["load-script@1.0.0", "", {}, "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA=="], - "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="], @@ -567,22 +562,16 @@ "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-router": ["react-router@7.17.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ=="], "react-router-dom": ["react-router-dom@7.17.0", "", { "dependencies": { "react-router": "7.17.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw=="], - "react-youtube": ["react-youtube@10.1.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "prop-types": "15.8.1", "youtube-player": "5.5.2" }, "peerDependencies": { "react": ">=0.14.1" } }, "sha512-ZfGtcVpk0SSZtWCSTYOQKhfx5/1cfyEW1JN/mugGNfAxT3rmVJeMbGpA9+e78yG21ls5nc/5uZJETE3cm3knBg=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -607,8 +596,6 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "sister": ["sister@3.0.2", "", {}, "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -667,8 +654,6 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "youtube-player": ["youtube-player@5.5.2", "", { "dependencies": { "debug": "^2.6.6", "load-script": "^1.0.0", "sister": "^3.0.0" } }, "sha512-ZGtsemSpXnDky2AUYWgxjaopgB+shFHgXVpiJFeNB5nWEugpW1KWYDaHKuLqh2b67r24GtP6HoSW5swvf0fFIQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -713,8 +698,6 @@ "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "youtube-player/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -804,7 +787,5 @@ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "youtube-player/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], } } diff --git a/packages/server/drizzle/0007_thankful_kitty_pryde.sql b/packages/server/drizzle/0007_thankful_kitty_pryde.sql new file mode 100644 index 0000000..24bbff3 --- /dev/null +++ b/packages/server/drizzle/0007_thankful_kitty_pryde.sql @@ -0,0 +1,2 @@ +ALTER TABLE `songs` ADD `source` text DEFAULT 'youtube' NOT NULL;--> statement-breakpoint +ALTER TABLE `songs` ADD `thumbnail` text; \ No newline at end of file diff --git a/packages/server/drizzle/meta/0007_snapshot.json b/packages/server/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..f2b368f --- /dev/null +++ b/packages/server/drizzle/meta/0007_snapshot.json @@ -0,0 +1,348 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "72d6e370-5da3-49c6-89ea-79a3f86c6924", + "prevId": "affa1764-e44d-491e-a680-8aa8736a45fa", + "tables": { + "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": {} + }, + "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 97b76ae..d3c1404 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1781985296752, "tag": "0006_fancy_exiles", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1782853391768, + "tag": "0007_thankful_kitty_pryde", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/commands.ts b/packages/server/src/commands.ts index 4793633..145562d 100644 --- a/packages/server/src/commands.ts +++ b/packages/server/src/commands.ts @@ -8,7 +8,7 @@ export const commands = [ .setName("play") .setDescription("Add a song to the queue and start playing") .addStringOption((option) => - option.setName("url").setDescription("YouTube URL").setRequired(true) + option.setName("url").setDescription("YouTube or SoundCloud URL").setRequired(true) ), new SlashCommandBuilder() .setName("listen") diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 35c4c5c..5e5e36a 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -23,10 +23,15 @@ export const songs = sqliteTable("songs", { roomId: text("room_id") .notNull() .references(() => rooms.id), + // Source-native track id: YouTube's 11-char id, or SoundCloud's numeric id. videoId: text("video_id").notNull(), + source: text("source").notNull().default("youtube"), // "youtube" | "soundcloud" url: text("url").notNull(), title: text("title"), uploader: text("uploader"), + // Artwork URL — only set for sources (SoundCloud) with no derivable thumbnail + // CDN pattern. YouTube thumbnails are derived client-side from videoId. + thumbnail: text("thumbnail"), addedBy: text("added_by"), addedByUserId: text("added_by_user_id"), votes: integer("votes").default(0), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6c915a6..086e8f6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -30,6 +30,7 @@ import { users, rooms, songs, votes, 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"; +import { detectSource, isSoundcloudSetUrl, type Source } from "./lib/sources"; import { encodeJwt, decodeJwt } from "./lib/jwt"; import { skipThreshold } from "./lib/voting"; @@ -203,12 +204,10 @@ function clientKey(c: any): string { // --- yt-dlp + ffmpeg audio stream --- // Returns the audio resource plus a cleanup() that kills both child processes, // so a finished/skipped/stopped track never leaves yt-dlp or ffmpeg lingering. -function createAudioStream(videoId: string): { +function createAudioStream(url: string): { resource: AudioResource; cleanup: () => void; } { - const url = `https://www.youtube.com/watch?v=${videoId}`; - const ytdlp = spawn( "yt-dlp", ["-f", "bestaudio", "--no-playlist", ...YTDLP_BASE_ARGS, ...YTDLP_DOWNLOAD_ARGS, "-o", "-", url], @@ -238,8 +237,8 @@ function createAudioStream(videoId: string): { ytdlp.stdout.pipe(ffmpeg.stdin); - ytdlp.on("error", (e) => console.error(`❌ yt-dlp error for ${videoId}:`, e)); - ffmpeg.on("error", (e) => console.error(`❌ ffmpeg error for ${videoId}:`, e)); + ytdlp.on("error", (e) => console.error(`❌ yt-dlp error for ${url}:`, e)); + ffmpeg.on("error", (e) => console.error(`❌ ffmpeg error for ${url}:`, e)); // If ffmpeg exits (for any reason), yt-dlp is no longer useful. ffmpeg.on("close", () => { @@ -253,7 +252,7 @@ function createAudioStream(videoId: string): { ytdlp.on("close", (code) => { if (code !== 0) { const tail = ytdlpErr.trim().split("\n").slice(-3).join(" | "); - console.error(`❌ yt-dlp exited ${code} for ${videoId}: ${tail || "(no stderr)"}`); + console.error(`❌ yt-dlp exited ${code} for ${url}: ${tail || "(no stderr)"}`); if (!ffmpeg.killed) { try { ffmpeg.kill("SIGKILL"); } catch {} } @@ -271,11 +270,10 @@ function createAudioStream(videoId: string): { }; } -function getVideoInfo(videoId: string): Promise<{ title: string; uploader: string | null }> { - const url = `https://www.youtube.com/watch?v=${videoId}`; - // spawn (not exec) passes the URL as a literal argv entry, never through a - // shell — this closes the command-injection sink that string interpolation - // into `yt-dlp --get-title "..."` opened. +// spawn (not exec) passes the URL as a literal argv entry, never through a +// shell — this closes the command-injection sink that string interpolation +// into `yt-dlp --get-title "..."` opened. +function getVideoInfo(url: string): Promise<{ title: string; uploader: string | null }> { return new Promise((resolve) => { const proc = spawn("yt-dlp", ["--get-title", "--no-warnings", ...YTDLP_BASE_ARGS, url], { stdio: ["ignore", "pipe", "ignore"], @@ -283,11 +281,73 @@ function getVideoInfo(videoId: string): Promise<{ title: string; uploader: strin }); let out = ""; proc.stdout.on("data", (chunk) => (out += chunk)); - proc.on("close", () => resolve({ title: out.trim() || videoId, uploader: null })); - proc.on("error", () => resolve({ title: videoId, uploader: null })); + proc.on("close", () => resolve({ title: out.trim() || url, uploader: null })); + proc.on("error", () => resolve({ title: url, uploader: null })); }); } +// Single yt-dlp round trip for a SoundCloud track: id, title, uploader, +// canonical webpage url, and the highest-res artwork available (SoundCloud has +// no predictable CDN pattern like YouTube's i.ytimg.com, so the URL must be +// captured here). Returns null on timeout/failure so callers can 400 instead of +// inserting a song with no usable metadata. +function resolveSoundcloudTrack(url: string): Promise<{ + videoId: string; + url: string; + title: string; + uploader: string | null; + thumbnail: string | null; +} | null> { + return new Promise((resolve) => { + const proc = spawn( + "yt-dlp", + ["--dump-json", "--no-playlist", "--no-warnings", ...YTDLP_BASE_ARGS, url], + { stdio: ["ignore", "pipe", "ignore"], timeout: 15000 } + ); + let output = ""; + proc.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + proc.on("close", () => { + try { + const d = JSON.parse(output.trim()); + if (!d.id || !d.title) return resolve(null); + const thumbnail = d.thumbnail ?? d.thumbnails?.at(-1)?.url ?? null; + resolve({ + videoId: String(d.id), + url: d.webpage_url || url, + title: String(d.title), + uploader: d.uploader ?? null, + thumbnail, + }); + } catch { + resolve(null); + } + }); + proc.on("error", () => resolve(null)); + }); +} + +// Shared by the HTTP add-song endpoint and the Discord /play command so the +// two surfaces can't drift on how a single track is resolved. +async function resolveSingleTrack( + url: string, + source: Source +): Promise<{ + videoId: string; + url: string; + title: string | null; + uploader: string | null; + thumbnail: string | null; +} | null> { + if (source === "youtube") { + const videoId = extractVideoId(url); + if (!videoId) return null; + const canonicalUrl = `https://www.youtube.com/watch?v=${videoId}`; + const { title, uploader } = await getVideoInfo(canonicalUrl); + return { videoId, url: canonicalUrl, title, uploader, thumbnail: null }; + } + return resolveSoundcloudTrack(url); +} + // Fully release a guild's playback: kill child processes, stop the player, // destroy the voice connection, and drop all per-guild state. Idempotent. function teardownGuild(guildId: string) { @@ -436,14 +496,29 @@ async function playNextFromRoomInner(roomId: string, guildId: string) { return; } - // Get title if not cached + // Get title if not cached. SoundCloud playlist/set entries also carry no + // title from flat-playlist resolution, and their stored url may still be the + // internal api-v2 url yt-dlp's flat-playlist gave us — resolving here also + // self-heals it to the public webpage_url. if (!nextSong.title) { - const { title, uploader } = await getVideoInfo(nextSong.videoId); - db.update(songs) - .set({ title, uploader }) - .where(eq(songs.id, nextSong.id)) - .run(); - nextSong.title = title; + if (nextSong.source === "soundcloud") { + const info = await resolveSoundcloudTrack(nextSong.url); + if (info) { + db.update(songs) + .set({ title: info.title, uploader: info.uploader, thumbnail: info.thumbnail, url: info.url }) + .where(eq(songs.id, nextSong.id)) + .run(); + nextSong.title = info.title; + nextSong.url = info.url; + } + } else { + const { title, uploader } = await getVideoInfo(nextSong.url); + db.update(songs) + .set({ title, uploader }) + .where(eq(songs.id, nextSong.id)) + .run(); + nextSong.title = title; + } } const connection = connections.get(guildId); @@ -457,7 +532,7 @@ async function playNextFromRoomInner(roomId: string, guildId: string) { console.log(`▶️ Playing in room ${roomId}: ${nextSong.title || nextSong.videoId}`); - const { resource, cleanup } = createAudioStream(nextSong.videoId); + const { resource, cleanup } = createAudioStream(nextSong.url); const player = setupPlayer(guildId); player.play(resource); @@ -518,9 +593,15 @@ app.get("/ready", async (c) => { } }); +// `source` picks how each flat-playlist entry's url is built: YouTube entries +// reliably carry only an id, so the canonical watch url is reconstructed; +// SoundCloud flat-playlist entries carry their own (sometimes internal API, +// not the public webpage) url and never a title — titles for those are filled +// in lazily by playNextFromRoomInner, same as any song missing a title. async function resolvePlaylist( - url: string -): Promise<{ title: string; entries: Array<{ videoId: string; title: string }> } | null> { + url: string, + source: Source +): Promise<{ title: string; entries: Array<{ videoId: string; title: string | null; url: string }> } | null> { return new Promise((resolve) => { const proc = spawn( "yt-dlp", @@ -533,8 +614,15 @@ async function resolvePlaylist( try { const data = JSON.parse(output.trim()); const entries = (data.entries || []) - .filter((e: any) => e.id && e.title) - .map((e: any) => ({ videoId: String(e.id), title: String(e.title) })); + .filter((e: any) => e.id) + .map((e: any) => ({ + videoId: String(e.id), + title: e.title ? String(e.title) : null, + url: source === "youtube" + ? `https://www.youtube.com/watch?v=${e.id}` + : String(e.webpage_url || e.url || ""), + })) + .filter((e: { url: string }) => e.url); resolve({ title: data.title || "Playlist", entries }); } catch { resolve(null); @@ -771,10 +859,13 @@ app.post("/api/rooms/:id/songs", async (c) => { const body = await c.req.json(); const { url } = body; + const source = detectSource(url); + if (!source) return c.json({ error: "Invalid YouTube or SoundCloud URL" }, 400); + await ensureRoom(id, user.id); - if (isPlaylistUrl(url)) { - const playlist = await resolvePlaylist(url); + if (isPlaylistUrl(url) || isSoundcloudSetUrl(url)) { + const playlist = await resolvePlaylist(url, source); if (!playlist || playlist.entries.length === 0) return c.json({ error: "Could not resolve playlist" }, 400); @@ -783,7 +874,8 @@ app.post("/api/rooms/:id/songs", async (c) => { playlist.entries.map((e) => ({ roomId: id, videoId: e.videoId, - url: `https://www.youtube.com/watch?v=${e.videoId}`, + source, + url: e.url, title: e.title, addedBy: user.username, addedByUserId: user.id, @@ -803,19 +895,19 @@ app.post("/api/rooms/:id/songs", async (c) => { return c.json({ playlistId, count: playlist.entries.length }, 201); } - const videoId = extractVideoId(url); - if (!videoId) return c.json({ error: "Invalid YouTube URL" }, 400); - - const { title, uploader } = await getVideoInfo(videoId); + const resolved = await resolveSingleTrack(url, source); + if (!resolved) return c.json({ error: "Could not resolve track" }, 400); const song = await db .insert(songs) .values({ roomId: id, - videoId, - url: `https://www.youtube.com/watch?v=${videoId}`, - title, - uploader, + videoId: resolved.videoId, + source, + url: resolved.url, + title: resolved.title, + uploader: resolved.uploader, + thumbnail: resolved.thumbnail, addedBy: user.username, addedByUserId: user.id, }) @@ -1022,12 +1114,22 @@ app.get("/api/search", async (c) => { if (!q) return c.json({ results: [] }); const n = Math.min(Number(c.req.query("n") || 5), 10); - - type SearchResult = { videoId: string; title: string; duration: number | null; uploader: string | null }; + const source: Source = c.req.query("source") === "soundcloud" ? "soundcloud" : "youtube"; + const searchPrefix = source === "youtube" ? "ytsearch" : "scsearch"; + + type SearchResult = { + source: Source; + videoId: string; + title: string; + duration: number | null; + uploader: string | null; + url: string; + thumbnail: string | null; + }; const results = await new Promise((resolve) => { const proc = spawn( "yt-dlp", - [`ytsearch${n}:${q}`, "--dump-json", "--flat-playlist", "--no-warnings", ...YTDLP_BASE_ARGS], + [`${searchPrefix}${n}:${q}`, "--dump-json", "--flat-playlist", "--no-warnings", ...YTDLP_BASE_ARGS], { stdio: ["ignore", "pipe", "ignore"] } ); @@ -1062,7 +1164,15 @@ app.get("/api/search", async (c) => { try { const e = JSON.parse(line); if (!e.id || !e.title) return []; - return [{ videoId: e.id, title: e.title, duration: e.duration ?? null, uploader: e.uploader ?? null }]; + const videoId = String(e.id); + const url = source === "youtube" + ? `https://www.youtube.com/watch?v=${videoId}` + : String(e.webpage_url || e.url || ""); + if (!url) return []; + const thumbnail = source === "youtube" + ? `https://i.ytimg.com/vi/${videoId}/default.jpg` + : (e.thumbnail ?? e.thumbnails?.at(-1)?.url ?? null); + return [{ source, videoId, title: String(e.title), duration: e.duration ?? null, uploader: e.uploader ?? null, url, thumbnail }]; } catch { return []; } @@ -1215,7 +1325,12 @@ discord.on(Events.InteractionCreate, async (interaction) => { if (interaction.commandName === "play") { const url = interaction.options.getString("url"); if (!url || !guildId) { - await interaction.reply("Provide a YouTube URL and be in a server"); + await interaction.reply("Provide a YouTube or SoundCloud URL and be in a server"); + return; + } + const source = detectSource(url); + if (!source) { + await interaction.reply("Invalid YouTube or SoundCloud URL"); return; } @@ -1234,20 +1349,21 @@ discord.on(Events.InteractionCreate, async (interaction) => { await ensureRoom(roomId, interaction.user.id); - const videoId = extractVideoId(url); - if (!videoId) { - await interaction.editReply("Invalid YouTube URL"); + const resolved = await resolveSingleTrack(url, source); + if (!resolved) { + await interaction.editReply("Could not resolve track"); return; } - - const { title, uploader } = await getVideoInfo(videoId); + const { title, uploader, thumbnail } = resolved; await db.insert(songs).values({ roomId, - videoId, - url: `https://www.youtube.com/watch?v=${videoId}`, + videoId: resolved.videoId, + source, + url: resolved.url, title, uploader, + thumbnail, addedBy: interaction.user.username, addedByUserId: interaction.user.id, }); diff --git a/packages/server/src/lib/sources.test.ts b/packages/server/src/lib/sources.test.ts new file mode 100644 index 0000000..ee14ded --- /dev/null +++ b/packages/server/src/lib/sources.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test"; +import { detectSource, isSoundcloudSetUrl } from "./sources"; + +test("detectSource: youtube hosts", () => { + expect(detectSource("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("youtube"); + expect(detectSource("https://youtu.be/dQw4w9WgXcQ")).toBe("youtube"); + expect(detectSource("https://music.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("youtube"); +}); + +test("detectSource: soundcloud hosts", () => { + expect(detectSource("https://soundcloud.com/forss/flickermood")).toBe("soundcloud"); + expect(detectSource("https://m.soundcloud.com/forss/flickermood")).toBe("soundcloud"); + expect(detectSource("https://on.soundcloud.com/abc123")).toBe("soundcloud"); +}); + +test("detectSource: rejects other hosts and garbage", () => { + expect(detectSource("https://example.com/watch?v=abc")).toBeNull(); + expect(detectSource("not a url at all")).toBeNull(); + expect(detectSource("")).toBeNull(); +}); + +test("isSoundcloudSetUrl: set/playlist url", () => { + expect(isSoundcloudSetUrl("https://soundcloud.com/forss/sets/soulhack")).toBe(true); +}); + +test("isSoundcloudSetUrl: plain track is not a set", () => { + expect(isSoundcloudSetUrl("https://soundcloud.com/forss/flickermood")).toBe(false); +}); + +test("isSoundcloudSetUrl: youtube url is never a soundcloud set", () => { + expect(isSoundcloudSetUrl("https://www.youtube.com/playlist?list=PLabc")).toBe(false); +}); diff --git a/packages/server/src/lib/sources.ts b/packages/server/src/lib/sources.ts new file mode 100644 index 0000000..b80f738 --- /dev/null +++ b/packages/server/src/lib/sources.ts @@ -0,0 +1,36 @@ +// Source detection — pure URL parsing, no I/O. The host allow-list here is the +// security gate that keeps yt-dlp from being pointed at an arbitrary host (SSRF); +// shell injection is separately closed by spawn() passing argv, never a shell. + +export type Source = "youtube" | "soundcloud"; + +const YOUTUBE_HOSTS = new Set(["youtube.com", "music.youtube.com", "youtu.be"]); +const SOUNDCLOUD_HOSTS = new Set(["soundcloud.com", "m.soundcloud.com", "on.soundcloud.com"]); + +function hostOf(url: string): string | null { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return null; + } +} + +export function detectSource(url: string): Source | null { + const host = hostOf(url); + if (!host) return null; + if (YOUTUBE_HOSTS.has(host)) return "youtube"; + if (SOUNDCLOUD_HOSTS.has(host)) return "soundcloud"; + return null; +} + +// A SoundCloud "set" (playlist/album) URL has /sets/ in its path, e.g. +// soundcloud.com//sets/. +export function isSoundcloudSetUrl(url: string): boolean { + const host = hostOf(url); + if (!host || !SOUNDCLOUD_HOSTS.has(host)) return false; + try { + return /\/sets\//.test(new URL(url).pathname); + } catch { + return false; + } +} diff --git a/packages/web/package.json b/packages/web/package.json index a1a46ac..108fe56 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,8 +11,7 @@ "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.0", - "react-youtube": "^10.1.0" + "react-router-dom": "^7.1.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.1", diff --git a/packages/web/src/components/SongItem.tsx b/packages/web/src/components/SongItem.tsx index c67ae54..4e0336d 100644 --- a/packages/web/src/components/SongItem.tsx +++ b/packages/web/src/components/SongItem.tsx @@ -4,9 +4,11 @@ import Glyph from "./Glyph"; interface Song { id: number; videoId: string; + source: "youtube" | "soundcloud"; url: string; title: string | null; uploader: string | null; + thumbnail: string | null; addedBy: string | null; addedByUserId: string | null; votes: number; diff --git a/packages/web/src/pages/Room.tsx b/packages/web/src/pages/Room.tsx index 23628b5..37dd731 100644 --- a/packages/web/src/pages/Room.tsx +++ b/packages/web/src/pages/Room.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import YouTube from "react-youtube"; import Navbar from "../components/Navbar"; import SongItem from "../components/SongItem"; import Waveform from "../components/Waveform"; @@ -9,12 +8,16 @@ import ReticleCorners from "../components/ReticleCorners"; import LyricsPanel from "../components/LyricsPanel"; import { useAuth } from "../hooks/useAuth"; +type Source = "youtube" | "soundcloud"; + interface Song { id: number; videoId: string; + source: Source; url: string; title: string | null; uploader: string | null; + thumbnail: string | null; addedBy: string | null; addedByUserId: string | null; votes: number; @@ -24,10 +27,13 @@ interface Song { } interface SearchResult { + source: Source; videoId: string; title: string; duration: number | null; uploader: string | null; + url: string; + thumbnail: string | null; } function fmtDuration(seconds: number | null): string { @@ -37,6 +43,13 @@ function fmtDuration(seconds: number | null): string { return `${m}:${s.toString().padStart(2, "0")}`; } +// YouTube thumbnails are derived from the videoId (no DB column needed); +// SoundCloud has no predictable CDN pattern so its artwork is stored verbatim. +function thumbUrl(s: { source: Source; videoId: string; thumbnail: string | null }, size = "hqdefault"): string | null { + if (s.thumbnail) return s.thumbnail; + return s.source === "youtube" ? `https://i.ytimg.com/vi/${s.videoId}/${size}.jpg` : null; +} + export default function Room() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -54,10 +67,12 @@ export default function Room() { const [connectStatus, setConnectStatus] = useState<"idle" | "loading" | "ok" | "err">("idle"); const [inputMode, setInputMode] = useState<"url" | "search">("url"); const [searchQuery, setSearchQuery] = useState(""); + const [searchSource, setSearchSource] = useState("youtube"); const [collapsedPlaylists, setCollapsedPlaylists] = useState>(new Set()); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const searchTimer = useRef | null>(null); + const [sending, setSending] = useState(false); // When the currently-playing track started (server clock), for lyric sync. const [currentSongStartedAt, setCurrentSongStartedAt] = useState(null); @@ -153,7 +168,8 @@ export default function Room() { const addSong = async (e: React.FormEvent) => { e.preventDefault(); - if (!newUrl.trim() || !id) return; + if (!newUrl.trim() || !id || sending) return; + setSending(true); try { const res = await fetch(`/api/rooms/${id}/songs`, { method: "POST", @@ -172,6 +188,8 @@ export default function Room() { } catch { setError("ERR_02: add_failed;"); setTimeout(() => setError(null), 3000); + } finally { + setSending(false); } }; @@ -220,7 +238,7 @@ export default function Room() { if (searchTimer.current) clearTimeout(searchTimer.current); searchTimer.current = setTimeout(async () => { try { - const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`); + const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}&source=${searchSource}`); const data = await res.json(); setSearchResults(data.results || []); } catch { @@ -230,7 +248,7 @@ export default function Room() { } }, 500); return () => { if (searchTimer.current) clearTimeout(searchTimer.current); }; - }, [searchQuery, inputMode]); + }, [searchQuery, inputMode, searchSource]); const addFromSearch = async (result: SearchResult) => { if (!id) return; @@ -239,7 +257,7 @@ export default function Room() { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ url: `https://www.youtube.com/watch?v=${result.videoId}` }), + body: JSON.stringify({ url: result.url }), }); if (res.ok) { setSearchQuery(""); @@ -471,35 +489,51 @@ export default function Room() {
-
- -
+ {thumbUrl(previewSong) ? ( + + ) : ( +
+ +
+ )}

{previewSong.title || previewSong.videoId}

+ + + {previewSong.source === "youtube" ? "_open_in_youtube;" : "_open_in_soundcloud;"} + ) : ( <> {/* Thumbnail with waveform overlay */}
- { - e.currentTarget.src = `https://i.ytimg.com/vi/${currentSong.videoId}/mqdefault.jpg`; - }} - /> + {thumbUrl(currentSong, "hqdefault") ? ( + { + if (currentSong.source === "youtube") { + e.currentTarget.src = `https://i.ytimg.com/vi/${currentSong.videoId}/mqdefault.jpg`; + } + }} + /> + ) : ( +
+ +
+ )} {/* gradient so ON_AIR badge and waveform are legible */}
@@ -597,13 +631,13 @@ export default function Room() { type="text" value={newUrl} onChange={(e) => setNewUrl(e.target.value)} - placeholder="_youtube_url;" + placeholder="_youtube_or_soundcloud_url;" className="flex-1 px-4 py-3 bg-ps-graphite-700 border border-white/10 text-ps-fg-inv-1 placeholder-ps-steel-400 font-mono text-sm tracking-wide focus:outline-none focus:border-ps-iris-rose/40 transition-all duration-120" style={{ transitionTimingFunction: "var(--ps-ease-print)" }} /> ) : ( -
- setSearchQuery(e.target.value)} - placeholder="_search_youtube;" - autoFocus - className="flex-1 px-4 py-3 bg-ps-graphite-700 border border-white/10 text-ps-fg-inv-1 placeholder-ps-steel-400 font-mono text-sm tracking-wide focus:outline-none focus:border-ps-iris-cyan/40 transition-all duration-120" - style={{ transitionTimingFunction: "var(--ps-ease-print)" }} - /> - {searching && ( -
- )} +
+
+ {(["youtube", "soundcloud"] as const).map((s) => ( + + ))} +
+
+ setSearchQuery(e.target.value)} + placeholder={searchSource === "youtube" ? "_search_youtube;" : "_search_soundcloud;"} + autoFocus + className="flex-1 px-4 py-3 bg-ps-graphite-700 border border-white/10 text-ps-fg-inv-1 placeholder-ps-steel-400 font-mono text-sm tracking-wide focus:outline-none focus:border-ps-iris-cyan/40 transition-all duration-120" + style={{ transitionTimingFunction: "var(--ps-ease-print)" }} + /> + {searching && ( +
+ )} +
)}
@@ -654,11 +706,17 @@ export default function Room() { className="group flex items-center gap-3 p-3 bg-ps-graphite-700 hover:bg-ps-graphite-600 border border-white/10 transition-all duration-120" style={{ transitionTimingFunction: "var(--ps-ease-print)" }} > - + {result.thumbnail ? ( + + ) : ( +
+ +
+ )}

{result.title}

From 63134dd9263ac831fd87f42dda071dc13d5c7c7a Mon Sep 17 00:00:00 2001 From: Nxssie Date: Tue, 30 Jun 2026 22:21:49 +0100 Subject: [PATCH 2/2] fix(rooms): anchor skip to the song actually streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skip endpoint and the songs poll both picked "current" as the highest-voted unplayed song. While a song plays it stays played=false in the DB, so once a pending song's votes overtake it mid-playback, both picked the wrong song: the vote-skip would 404/403 against votes that don't belong to what's on air, mark that wrong song played (never having streamed a second of it), and the real track would also get marked played when the player stopped — skipping two songs per click. Thread currentTracks' songId through as currentSongId so the skip route and the frontend's "now playing" both anchor to what's actually streaming, falling back to vote order only when nothing is (bot not connected). --- packages/server/src/index.ts | 42 ++++++++++++++++++++++----------- packages/web/src/pages/Room.tsx | 17 ++++++++++--- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 086e8f6..f4698cf 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -845,8 +845,14 @@ app.get("/api/rooms/:id/songs", async (c) => { const guildId = [...guildRoomMap.entries()].find(([, r]) => r === id)?.[0]; const track = guildId ? currentTracks.get(guildId) : null; const currentSongStartedAt = track?.startedAt ?? null; - - return c.json({ songs: roomSongs, userVotes, presentCount, currentSongStartedAt }); + // The song actually streaming, not just the highest-voted unplayed one — + // those diverge once a pending song's votes overtake the one already + // playing (it stays played=false until it finishes). null when nothing is + // actively streaming (bot not connected yet), so the frontend can fall back + // to the vote-order heuristic in that case. + const currentSongId = track?.songId ?? null; + + return c.json({ songs: roomSongs, userVotes, presentCount, currentSongStartedAt, currentSongId }); }); app.post("/api/rooms/:id/songs", async (c) => { @@ -967,12 +973,23 @@ app.post("/api/rooms/:id/skip", async (c) => { const presentCount = [...userCurrentRoom.values()].filter((r) => r === id).length; const threshold = skipThreshold(presentCount); - const current = await db - .select() - .from(songs) - .where(and(eq(songs.roomId, id), eq(songs.played, false))) - .orderBy(desc(songs.votes), songs.createdAt) - .get(); + const guildId = [...guildRoomMap.entries()].find(([, r]) => r === id)?.[0]; + const track = guildId ? currentTracks.get(guildId) : null; + + // Anchor to the song actually streaming (currentTracks), not the + // highest-voted unplayed one — those diverge once a pending song's votes + // overtake the one already playing (it stays played=false until it + // finishes), which would otherwise let a vote-skip mark the wrong song + // played while the real track keeps streaming. Fall back to the vote-order + // pick only when nothing is actively streaming (bot not connected). + 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 isOwner = current.addedByUserId === user.id; @@ -982,12 +999,9 @@ app.post("/api/rooms/:id/skip", async (c) => { // 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(); - for (const [guildId, roomId] of guildRoomMap) { - if (roomId === id) { - recentSkip.add(guildId); - players.get(guildId)?.stop(); - break; - } + 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)`); diff --git a/packages/web/src/pages/Room.tsx b/packages/web/src/pages/Room.tsx index 37dd731..548f01c 100644 --- a/packages/web/src/pages/Room.tsx +++ b/packages/web/src/pages/Room.tsx @@ -76,6 +76,11 @@ export default function Room() { // When the currently-playing track started (server clock), for lyric sync. const [currentSongStartedAt, setCurrentSongStartedAt] = useState(null); + // The song actually streaming right now, per the server's currentTracks — + // not just the highest-voted unplayed one (those diverge once a pending + // 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); // Signatures of the last applied payload, so an unchanged 4s poll doesn't // rebuild a new array and re-render the whole queue. @@ -101,6 +106,7 @@ export default function Room() { // Primitive setState with an unchanged value is a no-op re-render in React. setPresentCount(data.presentCount ?? 1); setCurrentSongStartedAt(data.currentSongStartedAt ?? null); + setCurrentSongId(data.currentSongId ?? null); } catch { setError("ERR_01: fetch_failed;"); } finally { @@ -142,9 +148,14 @@ export default function Room() { }; }, [id, user]); - // The first unplayed song is "now playing". Memoized so it isn't recomputed on - // every unrelated re-render (lyrics live in their own component now). - const currentSong = useMemo(() => songs.find((s) => !s.played), [songs]); + // Anchor to the song the server says is actually streaming; only fall back + // to "first unplayed by vote order" when nothing is actively streaming (bot + // not connected yet). Memoized so it isn't recomputed on every unrelated + // re-render (lyrics live in their own component now). + const currentSong = useMemo( + () => (currentSongId != null ? songs.find((s) => s.id === currentSongId) : songs.find((s) => !s.played)), + [songs, currentSongId] + ); const leaveRoom = () => navigate("/");