Add API documentation and underground station importer
- Introduced a new HTML documentation page for the oradio API, including a JavaScript file to handle dynamic content and API requests. - Added a CSS file for styling the documentation page. - Implemented an underground station importer script that fetches data from Radio-Browser and writes it to a JSON file. - Created a stats module to compute and manage vote and play statistics for radio stations. - Added a polyfill for modulepreload to ensure compatibility with older browsers.
This commit is contained in:
125
server/stats.js
Normal file
125
server/stats.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// 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<station_id, stats>.
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user