// 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; }