Juke is a collaborative event playlist builder. A host spins up a Room, guests join via link or 6-digit code (no account, no app download), everyone votes on songs, and the queue auto-sorts by votes in real time. When the party's done, the host exports the final playlist.
Built with Vue 3 + TypeScript + Vite + Tailwind + Pinia on the front end and Supabase (Postgres + Realtime + Edge Functions) on the back end.
No API keys required to run it. The song catalog comes from Deezer's public search API — no auth, no signup, and it ships 30-second preview MP3s so guests can actually hear a track before voting. For anything Deezer's licensed catalog misses — local-language releases, indie uploads, remixes — guests can paste a YouTube link, which also needs no key, and with a free YouTube Data API key Juke searches YouTube automatically when the catalog comes up empty. The host can export the finished queue to YouTube Music without logging in to anything. Spotify is a purely optional upgrade for hosts who have Premium (see Music catalog).
- Rooms with OPEN / LOCKED / CLOSED states, settings, cover art, genre tags, event countdown
- Anonymous guests — join with just a display name, identified by a localStorage session token
- Catalog search proxied through an Edge Function, 300 ms debounced typeahead, cached + rate-limited
- Two catalog sources — Deezer by default, with automatic YouTube fallback for the local-language, indie and remix tracks no licensed catalog carries; pasting a YouTube link always works, no API key needed
- 30-second previews — tap any album art to hear the track. No Premium, no Web Playback SDK, no login
- Real-time voting — optimistic UI, live queue reordering via Supabase Realtime, one toggleable vote per song per guest
- Host superpowers — veto, pin-to-top, ban guests, toggle room status, configure limits, connect Spotify, export playlist
- Dual-mode export — a copyable/downloadable ranked tracklist by default; a real one-click Spotify playlist if the host connects a Premium account
- Export to YouTube Music — the ranked queue as a YouTube / YouTube Music playlist, with no Google login, OAuth consent screen or Premium anything
- Hearts for engagement (no queue impact)
- QR code + native share for quick joining
- Dark/light theme, GSAP-free CSS animations, mobile tab layout, virtual scrolling past 100 songs
- Abuse protection — vote rate limits, burst detection auto-locks the room, IP-hashed search throttling, profanity filter
.
├── src/ # Vue front end
│ ├── components/ # SongCard, SearchPanel, QueuePanel, modals, …
│ ├── views/ # Landing, Create, Room, NotFound, SpotifyCallback
│ ├── stores/ # Pinia: room (realtime + optimistic), search
│ ├── lib/ # supabase client, api wrapper, session, preview audio, toast
│ └── types.ts
├── supabase/
│ ├── migrations/ # 0001_schema.sql, 0002_rls.sql, 0003_youtube_quota.sql, 0004_youtube_cache.sql
│ ├── seed.sql # demo room (code JUKE99)
│ ├── functions/
│ │ ├── _shared/ # cors, admin client, catalog, deezer, youtube, ytquota, ytcache, spotify, ratelimit, guest, util
│ │ ├── rooms/ # room lifecycle, guests, songs, votes, hearts, export
│ │ └── spotify/ # catalog search (Deezer + YouTube), host OAuth, playback
│ └── config.toml
├── scripts/keepalive.sh # twice-daily ping so the free tier doesn't pause
├── .github/workflows/ # keepalive.yml — the schedule for the above
├── .env.example # front-end vars (VITE_*)
└── supabase/functions/.env.example # server-side secrets
- Node 20+ and npm
- Supabase CLI (
npm i -g supabase) - Docker (for
supabase start) or a cloud Supabase project - Optional: a YouTube Data API v3 key (free, no billing) — enables automatic YouTube search when Deezer misses, and matching for YouTube Music export
- Optional: a Spotify developer app — only if a Premium host wants one-click playlist export
npm installYou'll need a Supabase account/project before the app can do anything.
- Cloud: create a project at supabase.com, then grab the Project URL, anon key, and service-role key from Project Settings → API.
- Local: run
supabase start(spins up Postgres + Studio + Edge runtime in Docker). The CLI prints the local URL and keys.
cp .env.example .env
cp supabase/functions/.env.example supabase/functions/.envFill .env with your VITE_SUPABASE_URL + VITE_SUPABASE_ANON_KEY, and
supabase/functions/.env with the service-role key. YOUTUBE_API_KEY and the SPOTIFY_*
vars can all stay empty — Deezer search, pasted YouTube links and both exports work without
them. Adding a YouTube key turns on automatic fallback search and lifts YouTube Music export
from "search links" to "matched playlist".
# link to your cloud project (skip if purely local)
supabase link --project-ref <your-ref>
# push migrations
supabase db push
# load the demo room (code: JUKE99)
supabase db execute --file supabase/seed.sql # cloud
# or for local:
psql "$LOCAL_DB_URL" -f supabase/seed.sql# set server-side secrets (cloud)
supabase secrets set --env-file supabase/functions/.env
# deploy
supabase functions deploy rooms
supabase functions deploy spotify
# — or run them locally —
supabase functions serve --env-file supabase/functions/.envnpm run devOpen http://localhost:5173 and visit /room/JUKE99 to see the seeded demo room.
GET /spotify/search?q= is backed by Deezer's public search API
— no auth, no API key, nothing to register. Results are normalized server-side into the
shape the front end consumes:
| field | source |
|---|---|
id |
dz:<deezer id> |
image |
album.cover_big |
duration |
Deezer seconds × 1000 (ms) |
preview_url |
Deezer's 30-second MP3 — plays in a plain <audio> element |
uri |
a Spotify search deep-link, used by the free export path |
Previews are why the album art is clickable everywhere in the UI: one shared
HTMLAudioElement in src/lib/preview.ts means only one track can play at a time.
Licensed catalogs are patchy outside the mainstream — local-language releases, indie uploads, remixes and live sets often just aren't there. YouTube is. Two paths, and only one of them needs configuring:
Paste a link (no key, no quota, always on). Drop any YouTube URL into the search box —
watch?v=, youtu.be/, /shorts/, music.youtube.com, or a bare video id — and it
resolves to a proposable track via YouTube's public oEmbed endpoint. This is the path
that makes "just add that one song" work regardless of server config.
Keyword search (needs a free key). Set YOUTUBE_API_KEY in supabase/functions/.env
(Google Cloud Console → enable YouTube Data API v3 → Credentials → API key; free, no
billing account). With a key set, Deezer misses fall through to YouTube automatically —
type a song Deezer's catalog doesn't carry, stop typing, and the results fill in from
YouTube with a "Not in the catalog — from YouTube" header. No button to find.
Google allots roughly 100
search.listcalls per project per day (100 units each out of 10,000) and there's no self-service way to buy more. That's the whole app's budget, so the fallback is engineered around it:
- It never fires from the typeahead. The 300 ms debounced request goes to Deezer only. When Deezer comes back empty, a 700 ms settle timer starts and any further keystroke cancels it — so a pause mid-word costs nothing and only the query the guest actually stopped on spends a search. The server never infers the fallback; the client must ask for it with
&fallback=1.- Results are cached in Postgres, not just in memory (
youtube_search_cache, migration0004). Edge Function isolates are ephemeral and Supabase spreads requests across them, so an in-memory cache alone lets every cold instance re-pay for the same query. Empty results are cached too — "YouTube has nothing for this" is worth not asking twice. 12h TTL.- Every call reserves units against a shared ledger first (
youtube_quota, migration0003), so concurrent isolates can't collectively overspend. Juke stops at 9,000 units (override withYOUTUBE_DAILY_UNIT_CAP), reportsyoutubeSearchesLeftto the client so the UI can stop offering what the server would refuse, and once Google returnsquotaExceededanyway it parks search until the daily reset and tells guests to paste links instead.Net cost: one search per song a guest genuinely can't find, once for the whole party.
Audio, not video — results are ranked like YouTube Music. There is no YouTube Music endpoint in Data API v3; YouTube Music is youtube.com filtered to music and played in an audio-first player. So Juke does the equivalent on the results it already paid for: every hit is scored song-vs-video and the releases float to the top, above music videos, lyric reuploads and live cuts. Strongest signals first:
| rank | what it is |
|---|---|
| 5 | an auto-generated "<Artist> - Topic" channel — this literally is YouTube Music's catalog: a label delivered the audio and YouTube built the channel for it |
| 4 | the artist's own channel, and the title says audio (Official Audio, visualiser, Full Audio) |
| 3 | the artist's own channel. For many songs the official video is the only official upload, and it's still the canonical recording — better than a stranger's copy |
| 2 | someone else's audio or lyric upload. Right song, unofficial copy |
| 1 | YouTube filed it under Music (categoryId 10) and nothing else stands out |
| 0 | a live cut, festival set, karaoke, cover, parody or dance video — not the record |
Two deliberate calls in there:
- Officialness beats format. An artist's own music video outranks a stranger's lyric
reupload, and tracks whose rank says "release" open in
music.youtube.com— the audio-first player — while videos keep the watch page, since a video would only land in an empty YT Music player. - The search is not restricted to
videoCategoryId=10. That filter is how you'd ask for "YouTube Music's catalog", but plenty of the regional and indie uploads that are the entire reason this source exists sit under Entertainment or People & Blogs because that's what the uploader picked. Excluding them would break the one job YouTube is here to do. Taking everything and ranking audio first costs the same 100 units and loses nothing.
Ranking rides along on the existing 1-unit videos.list enrichment call, so it spends no
extra quota. The UI labels a release YT Music and anything else YouTube.
Two more things to know about YouTube tracks:
- Artist and title are a guess. YouTube has no artist field, so we parse
"Artist - Title (Official Video) [4K]", stripping packaging noise while keeping real qualifiers like(Remix),(Live)and(feat. …). The original title is kept inraw_titleand shown on hover, and the UI badges these tracks as YouTube-sourced so a bad guess is legible rather than baffling. - Previews play in YouTube's embed, not our
<audio>element. Their API terms require playback through the official player, so Juke opens a smallyoutube-nocookieiframe instead. We never extract or proxy the audio stream. Starting one preview stops the other, so the room only ever plays one thing.
POST /rooms/:id/export/youtube turns the ranked queue into a YouTube playlist without the
host signing in to anything. It leans on YouTube's watch_videos endpoint, which builds a
throwaway playlist from a list of video ids — no account, no API key, no consent screen.
- YouTube-sourced tracks already carry their video id, so they cost nothing to export.
- Deezer tracks get matched with one search each, scored on title coverage plus an
artist bonus, with derivative uploads (reactions, dance challenges, karaoke, tutorials)
penalised and Official Audio /
- Topicuploads favoured. Below a confidence threshold the match is refused rather than guessed — a wrong video in the host's playlist is worse than an honest gap. - Matching is budgeted: 20 tracks per export, 5 concurrent, and every resolved id is
written back onto the song row (
spotify_track_data.youtube_id) so a second export of the same room costs zero quota — verified, not assumed. - Nothing fails, it degrades. Unmatched tracks each get a YouTube Music search link, so the export is useful even with no API key at all.
Why the host's browser opens the playlist. Resolving
watch_videosserver-side would let us hand back a directmusic.youtube.com/playlist?list=…URL, and Juke tries. But Google answers datacenter IPs — which is every Edge Function — with a 302 to their/sorry/captcha, under every header shape we tried. So when that happens the response setsneedsBrowserOpenand the host gets thewatch_videoslink instead: their browser follows the redirect fine, lands on the playlist in queue order, and Save there puts it in YouTube Music too — youtube.com and YouTube Music share one library. On a residential IP the direct YouTube Music link appears instead.
Playlists built this way cap at 50 videos (YouTube truncates silently, so Juke reports the cut) and are temporary until the host saves them.
If a host connects Spotify, POST /rooms/:id/export upgrades from "here's your ranked
tracklist" to "here's a real playlist in your account." Queue tracks come from Deezer and
YouTube, so none of them carry a Spotify id — export resolves each one against Spotify's
catalog by ISRC where available, then by name + artist, and reports anything it couldn't
match rather than quietly shipping a shorter playlist.
- Go to the Spotify Developer Dashboard and Create app.
- Copy the Client ID and Client Secret into
supabase/functions/.env(SPOTIFY_CLIENT_ID,SPOTIFY_CLIENT_SECRET). - Add this Redirect URI (must match exactly):
For local Edge functions:
https://<your-project-ref>.supabase.co/functions/v1/spotify/callbackhttp://localhost:54321/functions/v1/spotify/callback. - The host authenticates via Authorization Code flow. Scopes:
playlist-modify-public,playlist-modify-private,user-read-playback-state,user-modify-playback-state,user-read-currently-playing,streaming.
⚠️ As of February 2026, Spotify requires the app owner to hold Spotify Premium before the app can call the Web API at all — not just playback. A free developer account gets403 "Active premium subscription required for the owner of the app."on every endpoint, including catalog search. That's an account-level restriction: creating a new app or disabling the Web Playback SDK won't lift it. Hence Deezer as the default catalog, and hence Spotify being strictly optional.
All under /functions/v1. Guest-authenticated routes read an x-session-token header;
host-only routes additionally check the authenticated host_id.
| Method | Path | Notes |
|---|---|---|
| POST | /rooms |
create room, returns room + host session token |
| GET | /rooms/:code |
room + queue + playback (public) |
| PATCH | /rooms/:id |
update settings/status (host) |
| DELETE | /rooms/:id |
soft delete (host) |
| POST | /rooms/:code/join |
create/reuse guest session |
| GET | /rooms/:code/guests |
roster |
| POST | /rooms/:id/guests/:gid/ban |
ban guest (host) |
| POST | /rooms/:id/songs |
propose song (guest) |
| DELETE | /rooms/:id/songs/:sid |
veto (host) |
| PATCH | /rooms/:id/songs/:sid/pin |
pin/unpin (host) |
| POST | /rooms/:id/songs/:sid/vote |
vote {voteType:1|-1}, toggles |
| POST | /rooms/:id/songs/:sid/heart |
toggle heart |
| POST | /rooms/:id/export |
export final queue (host) — ranked tracklist, or a real Spotify playlist if the host connected Premium |
| POST | /rooms/:id/export/youtube |
export final queue to a YouTube / YouTube Music playlist (host) — no login, no OAuth |
| GET | /spotify/search?q=&source=&fallback= |
catalog search proxy — deezer (default, no keys) or youtube; fallback=1 lets an empty Deezer result spend one YouTube search; a pasted YouTube URL auto-resolves |
| GET | /spotify/resolve?url= |
pasted YouTube link/id → one track (keyless, no quota) |
| GET | /spotify/auth?roomId= |
begin host OAuth |
| GET | /spotify/callback |
OAuth callback → stores tokens |
| GET | /spotify/playback?roomId= |
host now-playing (poll) |
| PUT | /spotify/playback/play |
play/resume |
| PUT | /spotify/playback/pause |
pause |
| POST | /spotify/playback/next |
skip |
Clients subscribe to Postgres changes on songs, rooms, and playback_state, filtered by
room, over a room:{code} channel. Votes update songs.vote_count via a database trigger, so
a single vote fans out to every connected client automatically. The queue re-ranks client-side:
pinned first, then vote_count desc, then created_at asc (older wins ties).
Front end (Vercel/Netlify):
npm run build # outputs dist/Set VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_PUBLIC_APP_URL in the host's env.
SPA fallback is needed — both platforms handle it, or add a rewrite of /* → /index.html.
Back end (Supabase): push migrations + deploy functions + set secrets as in steps 4–5.
Point PUBLIC_APP_URL at your deployed domain — and, if you're using the optional Spotify
integration, the Spotify redirect URI too.
Free Supabase projects pause after about a week with no activity, and un-pausing needs a
manual click in the dashboard. scripts/keepalive.sh does a two-byte PostgREST read
(/rest/v1/rooms?select=id&limit=1) so the request lands on Postgres — pinging an Edge
Function only wakes the function runtime, which isn't the thing being measured. Zero rows is
still a pass; the select policy is conditional, so an empty array means the query ran fine.
.github/workflows/keepalive.yml runs it at 06:17 and 18:43 UTC. Add the two secrets once:
gh secret set SUPABASE_URL # https://your-ref.supabase.co
gh secret set SUPABASE_ANON_KEY # the anon public key
gh workflow run "Supabase keepalive" # prove it without waiting for 06:17Prefer not to lean on Actions? Same script, local cron:
17 6,18 * * * /path/to/juke/scripts/keepalive.sh >> /tmp/juke-keepalive.log 2>&1Either is plenty — twice a day against a seven-day window. The script reads its credentials
from the environment first and falls back to .env, retries three times with backoff before
failing, and exits non-zero so the scheduler actually tells you when it breaks.
- Create room → copy link → incognito → join → propose → upvote → host sees it live
- Host pins a song → stays on top regardless of votes
- Tap album art in search results and in the queue → 30s preview plays, second tap stops it
- Start a second preview → the first one stops (only one at a time)
- Paste a YouTube link in the search box → resolves to one proposable track
- Propose it → art tap opens the YouTube embed, and starting an MP3 preview closes it
- Leave the room mid-preview → audio and embed both stop
- Export a 50-song queue → order matches the queue
- Export with no Spotify connected → ranked tracklist appears, Copy + Download .txt work
- Export a queue mixing Deezer and YouTube tracks → each row links to its own source
- Search a song Deezer doesn't carry, then stop typing → results fill in from YouTube on their own
- Keep typing through a miss → no YouTube search fires (watch
youtubeSearchesLefthold steady) - Repeat the same YouTube-fallback search → served from cache,
youtubeSearchesLeftunchanged - Search a local-language song on YouTube → audio releases rank above music videos, and karaoke/live/cover uploads sink to the bottom
- A result badged YT Music opens
music.youtube.com; one badged YouTube opens the watch page - Export to YouTube Music → playlist opens in queue order; a second export of the same room spends no quota
- 100+ songs scroll smoothly (virtual scroller kicks in)
- Search "Bohemian Rhapsody" → correct result
- Ban a guest → their songs removed, votes recalculated
- Room auto-locks after the event date passes (
auto_lock_expired_events()) -
./scripts/keepalive.shprintskeepalive ok — 200and exits 0; with a bad key it exits 1
rooms.subscription_tier gates free-tier limits in the Edge Functions:
free = 1 room, 50 songs, 20 guests. Flip to premium to lift them.
MIT — do what you like, just keep the notice.
Juke talks to Deezer, YouTube and Spotify through their public APIs and stays inside their
terms: YouTube audio plays only in the official youtube-nocookie embed and no stream is
ever extracted or proxied. Music itself isn't covered by this license — the catalogs belong
to their rightsholders.
Built like it's shipping to Product Hunt tomorrow. The people have spoken. 🗳️