Add host-approved party suggestions and live upvotes

This commit is contained in:
Jonathan Sykes
2026-10-03 14:52:10 +08:00
parent 75dbc468cc
commit d7721045fb
10 changed files with 101 additions and 3 deletions

26
server/party-dj.js Normal file
View File

@@ -0,0 +1,26 @@
// Server-authoritative queue. Votes belong to the participant id across reconnects.
export function createDJQueue() { return { enabled: false, entries: [], sequence: 0 }; }
export function queueSnapshot(queue) {
return { enabled: queue.enabled, entries: [...queue.entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.size - a.votes.size || a.order - b.order).map(({ votes, order, ...entry }) => ({ ...entry, votes: [...votes] })) };
}
export function changeDJQueue(queue, message, participant, host) {
if (message.action === 'enable') { if (!host) return false; queue.enabled = !!message.enabled; return true; }
if (!queue.enabled) return false;
const entry = queue.entries.find(e => e.video.id === message.id);
if (message.action === 'suggest') {
const v = message.video; if (!v || !/^[A-Za-z0-9_-]{11}$/.test(v.id) || queue.entries.length >= 100 || queue.entries.some(e => e.video.id === v.id)) return false;
const clean = value => String(value || '').replace(/[\u0000-\u001f<>]/g, '').slice(0, 200);
queue.entries.push({ video: { id: v.id, title: clean(v.title) || v.id, channel: clean(v.channel), thumbnail: `/api/thumb/${v.id}` }, from: participant, approved: host, votes: new Set(), order: queue.sequence++ }); return true;
}
if (!entry) return false;
if (message.action === 'vote') { if (entry.votes.has(participant)) return false; entry.votes.add(participant); return true; }
if (!host) return false;
if (message.action === 'approve') { entry.approved = true; return true; }
if (message.action === 'remove') { queue.entries = queue.entries.filter(e => e !== entry); return true; }
return false;
}
export function takeDJVideo(queue) {
if (!queue.enabled) return null;
const next = queueSnapshot(queue).entries.find(e => e.approved); if (!next) return null;
queue.entries = queue.entries.filter(e => e.video.id !== next.video.id); return next.video;
}

12
server/party-dj.test.js Normal file
View File

@@ -0,0 +1,12 @@
import { test, expect } from 'bun:test';
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
test('host controls mode and approval; participant votes survive reconnects', () => {
const q = createDJQueue(); const suggest = { action: 'suggest', video: { id: '0gfX0dFLaBc', title: '<Song>' } };
expect(changeDJQueue(q, suggest, 'guest', false)).toBe(false); expect(changeDJQueue(q, { action: 'enable', enabled: true }, 'guest', false)).toBe(false);
changeDJQueue(q, { action: 'enable', enabled: true }, 'host', true); expect(changeDJQueue(q, suggest, 'guest', false)).toBe(true);
expect(changeDJQueue(q, suggest, 'guest', false)).toBe(false); expect(takeDJVideo(q)).toBeNull();
const vote = { action: 'vote', id: suggest.video.id }; expect(changeDJQueue(q, vote, 'guest', false)).toBe(true); expect(changeDJQueue(q, vote, 'guest', false)).toBe(false);
expect(changeDJQueue(q, { action: 'approve', id: suggest.video.id }, 'guest', false)).toBe(false);
changeDJQueue(q, { action: 'approve', id: suggest.video.id }, 'host', true); expect(queueSnapshot(q).entries[0].votes).toEqual(['guest']); expect(takeDJVideo(q).title).toBe('Song'); expect(q.entries).toHaveLength(0);
});
test('ranked approved queue consumes highest voted song, with bounded metadata', () => { const q = createDJQueue(); q.enabled = true; for (const id of ['abcdefghijk', 'ABCDEFGHIJK']) changeDJQueue(q, { action: 'suggest', video: { id, title: 'x'.repeat(1000), thumbnail: 'bad' } }, 'host', true); changeDJQueue(q, { action: 'vote', id: 'ABCDEFGHIJK' }, 'guest', false); expect(takeDJVideo(q).id).toBe('ABCDEFGHIJK'); expect(queueSnapshot(q).entries[0].video.title.length).toBe(200); expect(changeDJQueue(q, { action: 'suggest', video: { id: 'bad' } }, 'guest', false)).toBe(false); });

View File

@@ -18,6 +18,7 @@
* link); the host resumes it with the secret it was given.
* ========================================================================== */
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
@@ -88,7 +89,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
if (!p) {
const code = newCode();
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], allowControl: false, hostOnline: true, hostLeftAt: 0 };
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
parties.set(code, p);
}
p.hostPid = d.pid;
@@ -103,7 +104,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 });
send(ws, {
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid,
now: now(), members: members(p), state: p.state, chat: p.chat, allowControl: p.allowControl,
now: now(), members: members(p), state: p.state, chat: p.chat, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
});
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
}
@@ -117,7 +118,10 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
const me = p && p.members.get(d.pid);
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
const isHost = d.pid === p.hostPid;
if (m.type === 'state' && isHost) {
if (m.type === 'dj') {
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
} else if (m.type === 'state' && isHost) {
p.state = { ...(m.state || {}), ts: now() };
broadcast(p, { type: 'state', state: p.state }, ws);
} else if (m.type === 'settings' && isHost) {

View File

@@ -115,3 +115,14 @@ describe('watch party', () => {
expect(await guest.closed).toBe(4010);
});
});
test('DJ suggestions and votes are broadcast, host consumes approved songs', async () => {
const h = client({ role: 'host', pid: 'djhost00001', name: 'Host' }); const hello = await h.next('hello');
const g = client({ role: 'guest', pid: 'djguest0001', name: 'Guest', code: hello.code }); await g.next('hello');
h.send({ type: 'dj', action: 'enable', enabled: true }); expect((await g.next('dj')).enabled).toBe(true); await h.next('dj');
g.send({ type: 'dj', action: 'suggest', video: { id: '0gfX0dFLaBc', title: 'Song' } }); expect((await h.next('dj')).entries[0].approved).toBe(false); await g.next('dj');
g.send({ type: 'dj', action: 'vote', id: '0gfX0dFLaBc' }); expect((await h.next('dj')).entries[0].votes).toEqual(['djguest0001']); await g.next('dj');
h.send({ type: 'dj', action: 'approve', id: '0gfX0dFLaBc' }); expect((await g.next('dj')).entries[0].approved).toBe(true); await h.next('dj');
h.send({ type: 'dj', action: 'take' }); expect((await h.next('dj-play')).video.id).toBe('0gfX0dFLaBc'); expect((await g.next('dj')).entries).toEqual([]);
h.ws.close(); g.ws.close();
});