Skip to content

Repository files navigation

🎧 Juke — the playlist is a democracy

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).


✨ Features

  • 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

🧱 Project structure

.
├── 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

🚀 Local setup

0. Prerequisites

  • 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

1. Install

npm install

2. 👉 Create your Supabase project

You'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.

3. Configure environment

cp .env.example .env
cp supabase/functions/.env.example supabase/functions/.env

Fill .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".

4. Apply the database schema + seed

# 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

5. Deploy / serve the Edge Functions

# 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/.env

6. Run the front end

npm run dev

Open http://localhost:5173 and visit /room/JUKE99 to see the seeded demo room.


🎵 Music catalog & optional Spotify upgrade

Default: Deezer (zero config)

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.

Second source: YouTube, for what Deezer doesn't have

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.list calls 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, migration 0004). 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, migration 0003), so concurrent isolates can't collectively overspend. Juke stops at 9,000 units (override with YOUTUBE_DAILY_UNIT_CAP), reports youtubeSearchesLeft to the client so the UI can stop offering what the server would refuse, and once Google returns quotaExceeded anyway 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 in raw_title and 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 small youtube-nocookie iframe instead. We never extract or proxy the audio stream. Starting one preview stops the other, so the room only ever plays one thing.

Export to YouTube Music (no login, no Premium, no OAuth)

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 / - Topic uploads 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_videos server-side would let us hand back a direct music.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 sets needsBrowserOpen and the host gets the watch_videos link 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.

Optional: Spotify, for Premium hosts

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.

  1. Go to the Spotify Developer Dashboard and Create app.
  2. Copy the Client ID and Client Secret into supabase/functions/.env (SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET).
  3. Add this Redirect URI (must match exactly):
    https://<your-project-ref>.supabase.co/functions/v1/spotify/callback
    
    For local Edge functions: http://localhost:54321/functions/v1/spotify/callback.
  4. 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 gets 403 "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.


🔌 API surface (Edge Functions)

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

📡 Realtime

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).


☁️ Deployment

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.

Keeping the project awake

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:17

Prefer not to lean on Actions? Same script, local cron:

17 6,18 * * * /path/to/juke/scripts/keepalive.sh >> /tmp/juke-keepalive.log 2>&1

Either 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.


🧪 Testing checklist

  • 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 youtubeSearchesLeft hold steady)
  • Repeat the same YouTube-fallback search → served from cache, youtubeSearchesLeft unchanged
  • 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.sh prints keepalive ok — 200 and exits 0; with a bad key it exits 1

💸 Monetization hooks (architected in)

rooms.subscription_tier gates free-tier limits in the Edge Functions: free = 1 room, 50 songs, 20 guests. Flip to premium to lift them.


📄 License

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. 🗳️

About

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.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages