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:
1 parent
3e3505ba33
commit
d7ee6f1339
5 files changed
+40
-6
No files matched your search
@@ -46,7 +46,7 @@ async function direct(path, init = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listening positions kept by the hub (direct mode)
|
// 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) {
|
async function upstream(me, path, init = {}, retry = true) {
|
||||||
if (OA) return direct(path, init);
|
if (OA) return direct(path, init);
|
||||||
|
|||||||
+1
-1
@@ -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.
|
// 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 fs = require('fs'), path = require('path');
|
||||||
const users = require('./users'), genres = require('./genres');
|
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 BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
|
||||||
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
|
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
|
// 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 fs = require('fs'), path = require('path');
|
||||||
const users = require('./users');
|
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 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') : '';
|
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -3,13 +3,32 @@
|
|||||||
const fs = require('fs'), path = require('path');
|
const fs = require('fs'), path = require('path');
|
||||||
const users = require('./users');
|
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 file = () => path.join(users.dir(), name);
|
||||||
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
|
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
|
||||||
const merge = body => {
|
const merge = body => {
|
||||||
const mine = load();
|
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)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
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;
|
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
||||||
};
|
};
|
||||||
return { load, merge };
|
return { load, merge };
|
||||||
};
|
};
|
||||||
|
module.exports.stats = stats;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user