// Vote + play stats and the ranking algorithm. // // Score combines two signals: // - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N // - playLog = log10(plays + 1) gentle popularity boost // - score = voteZ + 0.5 * playLog // // Net effect: // * A handful of downvotes on an obscure station sinks it hard. // * One stray upvote on a brand new station barely moves it. // * Popular stations float up only if they aren't being actively buried. // * Established + positively-voted stations dominate the top. import { getDb } from './db/index.js'; export function computeScore({ up = 0, down = 0, plays = 0 } = {}) { const n = up + down; const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1); const playLog = Math.log10(plays + 1); return voteZ + 0.5 * playLog; } export function getStationStats(stationId, userId = null) { const db = getDb(); const v = db.prepare(` SELECT COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS up, COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down FROM station_votes WHERE station_id = ? `).get(stationId) || { up: 0, down: 0 }; const p = db.prepare('SELECT plays FROM station_plays WHERE station_id = ?').get(stationId); const plays = p?.plays || 0; let myVote = 0; if (userId) { const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId); myVote = r?.value || 0; } return { up: v.up, down: v.down, plays, myVote, score: computeScore({ up: v.up, down: v.down, plays }) }; } // Bulk stats for many stations in one query. Returns a Map. export function getStatsMap(userId = null) { const db = getDb(); const rows = db.prepare(` SELECT s.id AS station_id, COALESCE(v.up, 0) AS up, COALESCE(v.down, 0) AS down, COALESCE(p.plays, 0) AS plays FROM stations s LEFT JOIN ( SELECT station_id, SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END) AS up, SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END) AS down FROM station_votes GROUP BY station_id ) v ON v.station_id = s.id LEFT JOIN station_plays p ON p.station_id = s.id `).all(); const my = new Map(); if (userId) { for (const r of db.prepare('SELECT station_id, value FROM station_votes WHERE user_id = ?').all(userId)) { my.set(r.station_id, r.value); } } const out = new Map(); for (const r of rows) { const myVote = my.get(r.station_id) || 0; out.set(r.station_id, { up: r.up, down: r.down, plays: r.plays, myVote, score: computeScore({ up: r.up, down: r.down, plays: r.plays }) }); } return out; } export function castVote(userId, stationId, value) { const db = getDb(); if (value === 0 || value == null) { db.prepare('DELETE FROM station_votes WHERE user_id = ? AND station_id = ?').run(userId, stationId); } else if (value === 1 || value === -1) { db.prepare(` INSERT INTO station_votes (user_id, station_id, value) VALUES (?, ?, ?) ON CONFLICT(user_id, station_id) DO UPDATE SET value = excluded.value, created_at = CURRENT_TIMESTAMP `).run(userId, stationId, value); } else { throw new Error('vote value must be -1, 0 or 1'); } return getStationStats(stationId, userId); } export function recordPlay(stationId) { getDb().prepare(` INSERT INTO station_plays (station_id, plays, last_played_at) VALUES (?, 1, datetime('now')) ON CONFLICT(station_id) DO UPDATE SET plays = station_plays.plays + 1, last_played_at = datetime('now') `).run(stationId); } // Sort helper used by routes. Mutates the array. export function sortByMode(items, mode, statsMap) { const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, score: 0 }; switch (mode) { case 'hot': items.sort((a, b) => s(b.id).score - s(a.id).score || a.name.localeCompare(b.name)); break; case 'top': items.sort((a, b) => (s(b.id).up - s(b.id).down) - (s(a.id).up - s(a.id).down) || a.name.localeCompare(b.name)); break; case 'plays': items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name)); break; case 'controversial': items.sort((a, b) => { const A = s(a.id), B = s(b.id); return Math.min(B.up, B.down) - Math.min(A.up, A.down) || a.name.localeCompare(b.name); }); break; case 'name': default: items.sort((a, b) => a.name.localeCompare(b.name)); } return items; }