// Vote + play stats and the ranking algorithm. // // Score combines three signals: // - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N // - playLog = log10(plays + 1) gentle popularity boost // - timeLog = log10(hours_listened + 1) rewards actual listen time // - score = voteZ + 0.5 * playLog + 0.4 * timeLog // // Net effect: // * A handful of downvotes on an obscure station sinks it hard. // * One stray upvote on a brand new station barely moves it. // * Pressing play and skipping immediately barely counts; sticking with a // station for hours/days pushes it up the leaderboard. // * Established + positively-voted stations dominate the top. import { getDb } from './db/index.js'; // Sessions longer than this are almost certainly a forgotten-tab leak (laptop // closed, browser put to sleep). Clamp so one user can't poison total_play_ms. const MAX_SESSION_MS = 6 * 60 * 60 * 1000; // 6 hours // Sessions shorter than this don't get credited toward listen time (people // scrubbing through stations should still bump `plays`, but not `total_play_ms`). const MIN_SESSION_MS = 3 * 1000; export function computeScore({ up = 0, down = 0, plays = 0, totalPlayMs = 0 } = {}) { const n = up + down; const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1); const playLog = Math.log10(plays + 1); const hours = totalPlayMs / 3600000; const timeLog = Math.log10(hours + 1); return voteZ + 0.5 * playLog + 0.4 * timeLog; } function statsFromRow(r) { const up = r.up || 0; const down = r.down || 0; const plays = r.plays || 0; const sessions = r.sessions || 0; const totalPlayMs = r.total_play_ms || 0; const avgSessionMs = sessions > 0 ? Math.round(totalPlayMs / sessions) : 0; return { up, down, plays, sessions, totalPlayMs, avgSessionMs, score: computeScore({ up, down, plays, totalPlayMs }) }; } 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, sessions, total_play_ms FROM station_plays WHERE station_id = ? `).get(stationId) || {}; 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 { ...statsFromRow({ ...v, ...p }), myVote }; } // 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, COALESCE(p.sessions, 0) AS sessions, COALESCE(p.total_play_ms, 0) AS total_play_ms 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) { out.set(r.station_id, { ...statsFromRow(r), myVote: my.get(r.station_id) || 0 }); } 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); } // Record the start of a listening session. Bumps the play counter immediately // (so spam-clickers still register as taps) and opens a play_history row that // `endPlaySession` will close with a duration. Returns the new session id when // a user is known, or null for anonymous plays (which still bump the counter). export function recordPlay(stationId, userId = null, streamId = null) { const db = getDb(); db.prepare(` INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at) VALUES (?, 1, 0, 0, datetime('now')) ON CONFLICT(station_id) DO UPDATE SET plays = station_plays.plays + 1, last_played_at = datetime('now') `).run(stationId); if (!userId) return null; const info = db.prepare(` INSERT INTO play_history (user_id, station_id, stream_id, started_at) VALUES (?, ?, ?, datetime('now')) `).run(userId, stationId, streamId || null); return info.lastInsertRowid; } // Close a session opened by recordPlay. `durationMs` is optional — when the // client knows the real wall-clock listen time (e.g. an `audio.currentTime` // derivative) it should pass it; otherwise we compute it from started_at. // Returns the station_id we closed against, or null when the session is // unknown / already closed / belongs to someone else. export function endPlaySession(sessionId, userId, durationMs = null) { const db = getDb(); const row = db.prepare(` SELECT id, station_id, user_id, started_at, ended_at FROM play_history WHERE id = ? `).get(sessionId); if (!row || row.user_id !== userId || row.ended_at) return null; let ms = Number.isFinite(durationMs) && durationMs >= 0 ? Math.floor(durationMs) : Math.max(0, Date.now() - Date.parse(String(row.started_at).replace(' ', 'T') + 'Z')); if (!Number.isFinite(ms) || ms < 0) ms = 0; if (ms > MAX_SESSION_MS) ms = MAX_SESSION_MS; db.prepare(`UPDATE play_history SET ended_at = datetime('now') WHERE id = ?`).run(sessionId); // Only credit listen-time aggregates for "real" sessions; sub-second // sessions don't earn the station any score, but they already bumped // `plays` in recordPlay so they aren't completely free either. if (ms >= MIN_SESSION_MS) { db.prepare(` INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at) VALUES (?, 0, 1, ?, datetime('now')) ON CONFLICT(station_id) DO UPDATE SET sessions = station_plays.sessions + 1, total_play_ms = station_plays.total_play_ms + excluded.total_play_ms, last_played_at = datetime('now') `).run(row.station_id, ms); } return row.station_id; } // 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, totalPlayMs: 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 'playtime': items.sort((a, b) => s(b.id).totalPlayMs - s(a.id).totalPlayMs || 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; }