Last active
December 18, 2025 18:22
-
-
Save ghostdevv/3fe56e54e544f016fca04d3598197a51 to your computer and use it in GitHub Desktop.
listenbrainz listen time calculator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /// 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