Skip to content

Instantly share code, notes, and snippets.

@ghostdevv
Last active December 18, 2025 18:22
Show Gist options
  • Select an option

  • Save ghostdevv/3fe56e54e544f016fca04d3598197a51 to your computer and use it in GitHub Desktop.

Select an option

Save ghostdevv/3fe56e54e544f016fca04d3598197a51 to your computer and use it in GitHub Desktop.
listenbrainz listen time calculator
/// ListenBrainz Listen Time Calculator
/// Run this in a folder with the .jsonl files from a data export
/// deno run --allow-read --allow-env=TERM --allow-net=musicbrainz.org https://gist.githubusercontent.com/ghostdevv/3fe56e54e544f016fca04d3598197a51/raw/listen-time.ts
///
/// Supports `--cutoff <date>` if you want to only include
/// listens after a given date. E.g. `--cutoff 2025-11-20`
///
/// If musicbrainz has a rate limit issue then wait a few
/// seconds and re-try. Needs a built-in retry at some point.
import { blue, green, magenta, red, yellow } from 'jsr:@std/fmt@1/colors';
import { format as fmtDuration } from 'jsr:@std/fmt@1/duration';
import { extname } from 'jsr:@std/path@1';
import { ofetch } from 'npm:ofetch@1';
import * as v from 'npm:valibot@1';
import mri from 'npm:mri@1';
const listenSchema = v.object({
listened_at: v.pipe(
v.number(),
v.transform((v) => new Date(v * 1000)),
),
track_metadata: v.object({
mbid_mapping: v.optional(
v.nullable(
v.object({
recording_mbid: v.optional(v.string()),
}),
),
),
additional_info: v.object({
duration_ms: v.optional(v.number()),
duration: v.optional(v.number()),
}),
}),
});
const argsSchema = v.object({
cutoff: v.optional(v.pipe(v.string(), v.transform((v) => new Date(v)))),
});
const args = v.parse(argsSchema, mri(Deno.args, { string: ['cutoff'] }));
if (args.cutoff) {
console.log(
magenta('[info]'),
'cutoff date:',
args.cutoff.toISOString().replaceAll(/T|\.000Z/g, ' '),
);
}
function printError(error: Error) {
console.error(
red(
`[eror] ${error.message} cause: ${
Error.isError(error.cause) ? error.cause.message : `${error.cause}`
}`,
),
);
}
function tryJSONParse(str: string): Record<PropertyKey, unknown> | null {
try {
return JSON.parse(str);
} catch (error) {
printError(
new Error('failed to parse json', { cause: error }),
);
return null;
}
}
const durationCache = await caches.open('musicbrainz');
async function resolveDuration(listen: v.InferOutput<typeof listenSchema>): Promise<number | null> {
const info = listen.track_metadata.additional_info;
if (typeof info.duration_ms === 'number') {
return info.duration_ms;
}
if (typeof info.duration === 'number') {
return info.duration * 1000;
}
const recordingMBID = listen.track_metadata.mbid_mapping?.recording_mbid;
if (typeof recordingMBID !== 'string') {
return null;
}
try {
const url = `https://musicbrainz.org/ws/2/recording/${recordingMBID}`;
const res = await durationCache.match(url) ||
await fetch(url, { headers: { Accept: 'application/json' } });
await durationCache.put(url, res.clone());
const json = await res.json();
const duration = json.length;
if (typeof duration === 'number') {
return duration;
} else {
await durationCache.delete(url);
throw new Error(JSON.stringify(json));
}
} catch (error) {
printError(
new Error('failed to fetch duration', { cause: error }),
);
}
return null;
}
let listenTime = 0;
let listens = 0;
for await (const file of Deno.readDir('.')) {
if (file.isDirectory || extname(file.name) !== '.jsonl') {
continue;
}
console.log(blue('[scan]'), `processing ${file.name}`);
const contents = await Deno.readTextFile(file.name)
.catch((error) => new Error('failed to read file', { cause: error }));
if (Error.isError(contents)) {
printError(contents);
continue;
}
let lineNumber = 0;
for (const line of contents.trim().split('\n')) {
lineNumber++;
const raw = tryJSONParse(line);
if (!raw) continue;
const data = v.safeParse(listenSchema, raw);
if (!data.success) {
printError(
new Error('failed to parse line', {
cause: `${file.name}:${lineNumber} ${JSON.stringify(v.flatten(data.issues))}`,
}),
);
continue;
}
if (args.cutoff && data.output.listened_at > args.cutoff) {
continue;
}
const duration = await resolveDuration(data.output);
if (typeof duration != 'number') {
console.log(yellow('[warn]'), `${file.name}:${lineNumber} has missing duration`);
continue;
}
if (duration === 0) {
console.log(yellow('[warn]'), `${file.name}:${lineNumber} has duration of 0`);
}
listens++;
listenTime += duration;
}
}
console.log(green('[done]'), `listens found: ${listens}`);
console.log(green('[done]'), `listen time: ${Math.round((listenTime / 1000) / 50)} minutes`);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment