Files
ytplayer/frontend/stats-core.js

153 lines
6.8 KiB
JavaScript

/* ============================================================================
* stats-core.js — listening statistics (pure; window.StatsCore / node)
*
* Stored in data.stats (synced with the profile):
* { v: 1,
* days: { 'YYYY-MM-DD': { s: secondsListened, p: plays, songs: { id: plays } } },
* meta: { id: { t: title, c: channel } } }
* Days are LOCAL calendar days. A play counts once a song has been listened
* to for 30 s in one load. Days older than KEEP_DAYS are pruned.
* ========================================================================== */
(function (root) {
'use strict';
const KEEP_DAYS = 400;
const META_MAX = 600;
const STREAK_MIN_SEC = 5 * 60; // a day counts toward the streak at 5 min
const pad = (n) => String(n).padStart(2, '0');
const dayKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const addDays = (d, n) => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() + n); return x; };
function empty() { return { v: 1, days: {}, meta: {} }; }
function ensure(stats) {
if (!stats || typeof stats !== 'object' || !stats.days) return empty();
if (!stats.meta) stats.meta = {};
return stats;
}
// Add listened seconds (and optionally one play) for a song on `now`'s day.
function record(stats, song, seconds, countPlay, now = new Date()) {
stats = ensure(stats);
const k = dayKey(now);
const d = stats.days[k] || (stats.days[k] = { s: 0, p: 0, songs: {} });
if (seconds > 0) d.s = Math.round((d.s + seconds) * 10) / 10;
if (countPlay && song && song.id) {
d.p += 1;
d.songs[song.id] = (d.songs[song.id] || 0) + 1;
}
if (song && song.id) {
stats.meta[song.id] = { t: String(song.title || '').slice(0, 200), c: String(song.channel || '').slice(0, 120) };
const ids = Object.keys(stats.meta);
if (ids.length > META_MAX) {
const used = new Set();
Object.values(stats.days).forEach((x) => Object.keys(x.songs || {}).forEach((id) => used.add(id)));
for (const id of ids) if (!used.has(id) && Object.keys(stats.meta).length > META_MAX) delete stats.meta[id];
}
}
prune(stats, now);
return stats;
}
function prune(stats, now = new Date()) {
const cutoff = dayKey(addDays(now, -KEEP_DAYS));
for (const k of Object.keys(stats.days)) if (k < cutoff) delete stats.days[k];
return stats;
}
// "Hillsong Worship - Topic", "BethelVEVO" → artist name for grouping.
function artistOf(channel) {
return String(channel || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(/\s+Official$/i, '').trim() || 'Unknown';
}
function streaks(stats, now = new Date()) {
const active = (d) => ((stats.days[dayKey(d)] || {}).s || 0) >= STREAK_MIN_SEC;
// Current streak: ending today, or yesterday if today hasn't counted yet.
let cur = 0;
let d = active(now) ? now : addDays(now, -1);
while (active(d)) { cur++; d = addDays(d, -1); }
let best = 0, run = 0;
const keys = Object.keys(stats.days).sort();
if (keys.length) {
const [y, m, dd] = keys[0].split('-').map(Number);
for (let x = new Date(y, m - 1, dd); dayKey(x) <= dayKey(now); x = addDays(x, 1)) {
run = active(x) ? run + 1 : 0;
if (run > best) best = run;
}
}
return { current: cur, best: Math.max(best, cur) };
}
function summarize(statsIn, now = new Date()) {
const stats = ensure(statsIn);
const monthKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
const thisMonth = monthKey(now);
const lastMonth = monthKey(new Date(now.getFullYear(), now.getMonth() - 1, 1));
let thisMonthSec = 0, lastMonthSec = 0, totalSec = 0, totalPlays = 0;
const songsAll = {}, artistsMonth = {};
for (const [k, d] of Object.entries(stats.days)) {
totalSec += d.s || 0;
totalPlays += d.p || 0;
if (k.startsWith(thisMonth)) thisMonthSec += d.s || 0;
if (k.startsWith(lastMonth)) lastMonthSec += d.s || 0;
for (const [id, n] of Object.entries(d.songs || {})) {
songsAll[id] = (songsAll[id] || 0) + n;
if (k.startsWith(thisMonth)) {
const a = artistOf((stats.meta[id] || {}).c);
artistsMonth[a] = (artistsMonth[a] || 0) + n;
}
}
}
const topArtists = Object.entries(artistsMonth).sort((a, b) => b[1] - a[1]).slice(0, 5)
.map(([name, plays]) => ({ name, plays }));
// Top songs over the last 30 days, each with a 30-point daily sparkline.
const last30 = Array.from({ length: 30 }, (_, i) => dayKey(addDays(now, i - 29)));
const recent = {};
for (const k of last30) for (const [id, n] of Object.entries((stats.days[k] || {}).songs || {})) recent[id] = (recent[id] || 0) + n;
const topSongs = Object.entries(recent).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, plays]) => ({
id, plays, title: (stats.meta[id] || {}).t || id, channel: (stats.meta[id] || {}).c || '',
spark: last30.map((k) => ((stats.days[k] || {}).songs || {})[id] || 0),
}));
// 16-week heatmap: columns are weeks (oldest → current), rows Mon..Sun.
const WEEKS = 16;
const monIdx = (d) => (d.getDay() + 6) % 7; // Mon=0 … Sun=6
const thisMonday = addDays(now, -monIdx(now));
const start = addDays(thisMonday, -(WEEKS - 1) * 7);
const heat = [];
for (let w = 0; w < WEEKS; w++) {
const col = [];
for (let r = 0; r < 7; r++) {
const d = addDays(start, w * 7 + r);
const k = dayKey(d);
col.push({ date: k, minutes: Math.round(((stats.days[k] || {}).s || 0) / 60), future: k > dayKey(now) });
}
heat.push(col);
}
// Peak weekday: average hours on that weekday across the heatmap window.
const byDow = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 }));
heat.forEach((col) => col.forEach((c, r) => { if (!c.future) { byDow[r].sum += c.minutes; byDow[r].n += 1; } }));
const names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
let peak = null;
byDow.forEach((x, i) => { const avg = x.n ? x.sum / x.n / 60 : 0; if (avg > 0 && (!peak || avg > peak.avgHours)) peak = { day: names[i], avgHours: avg }; });
return {
totalSec, totalPlays, thisMonthSec, lastMonthSec,
songsPlayed: Object.keys(songsAll).length,
streak: streaks(stats, now), topArtists, topSongs, heat, peak,
};
}
function fmtHours(sec) {
const m = Math.round((sec || 0) / 60);
const h = Math.floor(m / 60);
return h ? `${h}h ${pad(m % 60)}m` : `${m}m`;
}
const StatsCore = { empty, ensure, record, prune, summarize, streaks, artistOf, dayKey, fmtHours, STREAK_MIN_SEC };
if (typeof module !== 'undefined' && module.exports) module.exports = StatsCore;
else root.StatsCore = StatsCore;
})(typeof globalThis !== 'undefined' ? globalThis : this);