Listening stats: time listened per day, streak and finished audiobooks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 09:50:23 -04:00
1 parent 3e3505ba33
commit d7ee6f1339
5 files changed
+40 -6

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ async function direct(path, init = {}) {
}
// Listening positions kept by the hub (direct mode)
const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge;
const prog = require('./progress')('audiobook-progress.json', 'audiobook'), progLoad = prog.load, progMerge = prog.merge;
async function upstream(me, path, init = {}, retry = true) {
if (OA) return direct(path, init);
+1 -1
View File
@@ -2,7 +2,7 @@
// address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one.
const fs = require('fs'), path = require('path');
const users = require('./users'), genres = require('./genres');
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
const prog = require('./progress')('librivox-progress.json', 'audiobook'); // keyed lv:<book id>: seconds into the whole book
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
+1 -1
View File
@@ -2,7 +2,7 @@
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
const fs = require('fs'), path = require('path');
const users = require('./users');
const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode
const prog = require('./progress')('podcast-progress.json', 'podcast'); // where each person got to in each episode
const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, '');
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
+22 -3
View File
@@ -3,13 +3,32 @@
const fs = require('fs'), path = require('path');
const users = require('./users');
module.exports = name => {
// With a `kind` ('podcast' or 'audiobook'), each report also adds the time actually listened to that person's listening log (listening.json): seconds per day per kind, and
// the items finished. A jump forward bigger than the time that passed (a seek) does not count.
const day = t => { const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
const logFile = () => path.join(users.dir(), 'listening.json');
const loadLog = () => { try { const o = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return { days: o.days || {}, finished: o.finished || {} }; } catch { return { days: {}, finished: {} }; } };
const stats = () => {
const o = loadLog(), now = new Date(), today = day(now), tot = k => Object.values(o.days).reduce((n, d) => n + (d[k] || 0), 0), sum = (a, b) => Object.entries(o.days).filter(([k]) => k >= a && k <= b).reduce((n, [, d]) => n + (d.podcast || 0) + (d.audiobook || 0), 0);
const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60;
let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++;
const year = String(now.getFullYear());
return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length };
};
module.exports = (name, kind) => {
const file = () => path.join(users.dir(), name);
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
const merge = body => {
const mine = load();
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
const mine = load(), log = kind ? loadLog() : null; let logged = false;
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) {
const old = mine[k], n = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
if (log && old && n.t > old.t) { const wall = (n.t - old.t) / 1000, gain = n.pos - old.pos; if (gain > 0 && gain <= wall * 2 + 5 && wall < 600) { const d = log.days[day(n.t)] = log.days[day(n.t)] || {}; d[kind] = Math.round(((d[kind] || 0) + Math.min(gain, wall + 5)) * 10) / 10; logged = true; } }
if (log && n.dur > 60 && n.pos >= n.dur - 30 && !log.finished[k]) { log.finished[k] = { t: n.t || Date.now(), kind }; logged = true; }
mine[k] = n;
}
if (logged) { for (const k of Object.keys(log.days).sort().slice(0, -400)) delete log.days[k]; const lf = logFile(), lt = lf + '.tmp'; fs.writeFileSync(lt, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(lt, lf); }
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
};
return { load, merge };
};
module.exports.stats = stats;
+15
View File
@@ -0,0 +1,15 @@
// Listening stats: time listened is counted from progress reports (seeks are not), per day and kind; finished items and the day streak are worked out.
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-listen-'));
const users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), progress = require('../progress');
const pod = progress('podcast-progress.json', 'podcast'), ab = progress('audiobook-progress.json', 'audiobook');
users.runAs(me, () => {
const t0 = Date.now() - 60e3;
pod.merge({ e1: { pos: 100, dur: 3000, t: t0 } }); pod.merge({ e1: { pos: 130, dur: 3000, t: t0 + 30e3 } }); // 30 s listened in 30 s
pod.merge({ e1: { pos: 1500, dur: 3000, t: t0 + 40e3 } }); // a seek: not counted
ab.merge({ b1: { pos: 7000, dur: 7100, t: t0 } }); ab.merge({ b1: { pos: 7090, dur: 7100, t: t0 + 50e3 } }); // 50 s counted at most, and the book is finished
let s = progress.stats(); assert.equal(Math.round(s.podcast), 30, 'only real listening counts'); assert.ok(s.audiobook > 0 && s.audiobook <= 55); assert.equal(s.finishedYear, 1); assert.equal(s.finishedAll, 1);
pod.merge({ e1: { pos: 1530, dur: 3000, t: t0 + 70e3 } }); s = progress.stats(); assert.equal(s.streak, 1, 'a minute or more makes a streak day');
const old = progress('podcast-progress.json', 'podcast'); old.merge({ e1: { pos: 5, dur: 3000, t: t0 - 1e3 } }); assert.equal(progress.stats().podcast, s.podcast, 'an older report changes nothing');
console.log('listening test ok');
});