Skip to content

Instantly share code, notes, and snippets.

@OFRBG
Created July 24, 2024 13:13
Show Gist options
  • Save OFRBG/c95fa7d7ddf3f2bb6e9c35f44fd391dc to your computer and use it in GitHub Desktop.
Save OFRBG/c95fa7d7ddf3f2bb6e9c35f44fd391dc to your computer and use it in GitHub Desktop.
import { ReadStream, openSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import url from "node:url";
import { EventEmitter } from "node:events";
import { promisify } from "node:util";
import {
render,
Spacer,
Text,
Box,
Newline,
Transform,
useApp,
useInput,
} from "ink";
import Spinner from "ink-spinner";
import { html } from "htm/react";
import { Temporal } from "@js-temporal/polyfill";
import { useEffect, useState, useMemo, useRef } from "react";
import { compareAsc, format } from "date-fns";
import SpotifyWeb from "spotify-web-api-node";
import DataLoader from "dataloader";
import terminalLink from "terminal-link";
import { Level } from "level";
import * as dotenv from "dotenv";
dotenv.config();
const SERVER_PATH = "/spotify/callback";
const UNITS = [
{ label: "minutes", unit: "minute", short: "mi" },
{ label: "hours", unit: "hour", short: "hr" },
{ label: "days", unit: "day", short: "dy" },
{ label: "months", unit: "month", short: "mo" },
{ label: "years", unit: "year", short: "yr" },
];
const db = new Level("spotify.db", { valueEncoding: "json" });
const spotify = new SpotifyWeb({
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
redirectUri: `http://localhost:56201${SERVER_PATH}`,
});
class LoaderEmitter extends EventEmitter {}
const loaderEmitter = new LoaderEmitter();
const startServer = (setReady) =>
http
.createServer(async (req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
if (req.method === "GET" && url.parse(req.url).pathname === SERVER_PATH) {
const searchParams = new URLSearchParams(url.parse(req.url).query);
const accessCode = searchParams.get("code");
const data = await spotify.authorizationCodeGrant(accessCode);
spotify.setAccessToken(data.body.access_token);
setReady(true);
res.write("You can close this window");
} else {
res.statusCode = 404;
res.write(res.statusCode.toString());
}
res.end();
})
.listen(56201, "127.0.0.1");
const fetchRetry = async (ids) => {
try {
const tracks = await spotify.getTracks(ids);
return tracks.body.tracks;
} catch (err) {
const after = err.headers["retry-after"];
if (after === undefined) throw err;
await new Promise((resolve) =>
setTimeout(resolve, (parseInt(after) + 1) * 1000)
);
return fetchRetry(ids);
}
};
const loadTracks = async (ids) => {
const dbTracks = await db.getMany(ids);
const missing = [];
for (let index = 0; index < dbTracks.length; index++) {
if (!dbTracks[index]) {
missing.push(ids[index]);
}
}
let apiTracks = [];
if (missing.length) {
try {
apiTracks = await fetchRetry(missing);
} catch (err) {
loaderEmitter.emit("error", err, ids);
}
}
const ops = [];
for (let index = 0; index < dbTracks.length; index++) {
if (dbTracks[index]) continue;
dbTracks[index] = apiTracks[index];
if (apiTracks[index]) {
ops.push({ type: "put", key: ids[index], value: apiTracks[index] });
}
delete apiTracks[trackIndex];
}
await db.batch(ops);
loaderEmitter.emit("end");
return dbTracks;
};
const trackLoaderOptions = {
maxBatchSize: 50,
batchScheduleFn: (cb) => {
loaderEmitter.emit("start");
const interval = setInterval(() => {
if (spotify.getAccessToken()) {
cb();
clearInterval(interval);
}
}, 1000);
},
};
const trackLoader = new DataLoader(loadTracks, trackLoaderOptions);
const getFormatter = (unit) => {
switch (unit) {
case "%":
return new Intl.NumberFormat("en", { style: "percent" });
case undefined:
return new Intl.NumberFormat("en", { style: "decimal" });
default:
return new Intl.NumberFormat("en", {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits: 2,
});
}
};
const msToUnit = (ms, unit) =>
Temporal.Duration.from({ milliseconds: ms }).total({
unit,
relativeTo: "2020-01-01",
});
const fileHandles = function* () {
let index = 0;
while (true) {
const filename = path.join("streaming", `endsong_${index}.json`);
try {
index++;
yield { fd: openSync(filename, "r"), filename };
} catch (err) {
return err;
}
}
};
function parsePartial({ current }) {
const matchJson = /(?<inner>({.*}),?)+/.exec(current.buffer);
let substring = matchJson.groups.inner;
current.buffer =
current.buffer.slice(0, matchJson.index) +
current.buffer.slice(matchJson.index + matchJson.groups.inner.length);
if (substring[substring.length - 1] === ",") {
substring = substring.slice(0, -1);
}
const data = JSON.parse(`[${substring}]`);
for (const entry of data) {
const ts = new Date(entry.ts);
current.dateRange[0] ||= ts;
current.dateRange[1] ||= ts;
if (compareAsc(ts, current.dateRange[0]) === -1) {
current.dateRange[0] = ts;
}
if (compareAsc(ts, current.dateRange[1]) === 1) {
current.dateRange[1] = ts;
}
current.totalTime += entry.ms_played;
current.songs++;
if (entry.spotify_track_uri) {
const uri = entry.spotify_track_uri.split(":")[2];
current.uniqueSongs.add(entry.master_metadata_track_name);
trackLoader.load(uri);
} else {
current.deletedSongs++;
}
}
}
const File = ({
file: { fd, filename },
addTime,
setPending,
addSongs,
addUniqueSongs,
}) => {
const file = useRef(new ReadStream(null, { fd }));
const stateRef = useRef({
buffer: 0,
songs: 0,
totalTime: 0,
dateRange: [null, null],
deletedSongs: 0,
uniqueSongs: new Set(),
});
const [done, setDone] = useState(false);
const [dateRange, setDateRange] = useState([null, null]);
const [totalSeconds, setTotalSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setTotalSeconds(msToUnit(stateRef.current.totalTime, "minutes"));
setDateRange(stateRef.current.dateRange);
}, 100);
return () => {
clearInterval(interval);
};
}, []);
useEffect(() => {
file.current
.on("data", (chunk) => {
stateRef.current.buffer += chunk;
parsePartial(stateRef);
})
.on("end", () => {
addTime(stateRef.current.totalTime);
addSongs(stateRef.current.songs);
addUniqueSongs(stateRef.current.uniqueSongs);
setDone(true);
setPending((pending) => pending - 1);
});
}, [fd]);
const color = done ? "green" : "yellow";
const sd = dateRange[0] ? format(dateRange[0], "MMM yy") : "...";
const ed = dateRange[1] ? format(dateRange[1], "MMM yy") : "...";
return html`
<${Box} justifyContent="space-between" paddingX="1">
<${Box} gap="1" flexGrow="1">
<${Text} color="blue">
${!done ? html`<${Spinner} type="arc" />` : "✓"}
<//>
<${Text} color=${color}>${filename}<//>
<//>
<${Box} flexBasis="25" justifyContent="flex-end">
<${Text} color="black">${sd} - ${ed}<//>
<//>
<${Box} flexBasis="30" justifyContent="flex-end">
<${Text}>${getFormatter("second").format(totalSeconds)}<//>
<//>
<//>
`;
};
const Spotify = () => {
const [totalMs, setTotalTime] = useState(0);
const [songs, setSongs] = useState(0);
const [uniqueSongs, setUniqueSongs] = useState(new Set());
const [unit, setUnit] = useState(0);
const [authUrl, setAuthUrl] = useState();
const [ready, setReady] = useState(false);
const [downloads, setDownloads] = useState(0);
const [failures, setFailures] = useState(0);
useEffect(() => {
const start = loaderEmitter.on("start", () => {
setDownloads((downloads) => downloads + 1);
});
const end = loaderEmitter.on("end", () => {
setDownloads((downloads) => downloads - 1);
});
const error = loaderEmitter.on("error", () => {
setDownloads((downloads) => downloads - 1);
setFailures((failures) => failures + 1);
});
() => {
start();
end();
error();
};
}, []);
useEffect(() => {
setAuthUrl(spotify.createAuthorizeURL([]));
startServer(setReady);
}, []);
const handles = useMemo(() => Array.from(fileHandles()), []);
const [pending, setPending] = useState(handles.length);
const { exit } = useApp();
useInput((input, key) => {
if (input === "q" || (key.ctrl && input === "c")) {
exit();
}
if (key.leftArrow) {
setUnit((unit) => (unit + UNITS.length - 1) % UNITS.length);
}
if (key.rightArrow) {
setUnit((unit) => (unit + 1) % UNITS.length);
}
});
const addTime = (time) => {
setTotalTime((totalTime) => totalTime + parseInt(time));
};
const addSongs = (songs) => {
setSongs((totalSongs) => totalSongs + songs);
};
const addUniqueSongs = (songs) => {
songs.forEach((song) => setUniqueSongs(uniqueSongs.add(song)));
};
return html`
<${Box} flexDirection="column" borderStyle="round" padding="1">
<${Box}
gap="1"
paddingX="1"
alignItems="flex-end"
justifyContent="space-between"
>
<${Text} bold color="yellow">Spotify Streaming Summary<//>
<${Box} borderStyle="single" gap="1" paddingX="1">
<${Text} color="blue">exit: q<//>
<${Text} bold color="white">|<//>
<${Text} color="blue">change unit: arrows<//>
<//>
<//>
<${Box} flexDirection="column" borderStyle="round">
<${Box} gap="1" paddingX="1">
<${Text} bold color="white">Total stream time:<//>
<${Text} color="green">
${getFormatter(UNITS[unit].unit).format(
msToUnit(totalMs, UNITS[unit].label)
)}
<//>
<${Spacer} />
<${Box} gap="1">
${UNITS.map(
({ label, short }, index) =>
html`
<${Text}
key=${label}
color="${index === unit ? "yellow" : "black"}"
>
${short}
<//>
`
)}
<//>
<//>
<${Box} gap="1" paddingX="1">
<${Text} bold color="white">Total songs:<//>
<${Text} color="green">${getFormatter().format(songs)}<//>
<//>
<${Box} gap="1" paddingX="1">
<${Text} bold color="white">Total unique songs:<//>
<${Box} gap="1" paddingX="1">
<${Text} color="green">
${getFormatter().format(uniqueSongs.size)}
<//>
<${Text} color="blue">
(${getFormatter("%").format(uniqueSongs.size / songs || 0)})
<//>
<//>
<//>
<//>
<${Newline} />
<${Box} flexDirection="column">
<${Box} marginBottom="0" gap="1">
<${Text}>Queued downloads: ${downloads}<//>
${ready &&
html`
<${Text} color="${failures > 0 ? "red" : "green"}" bold>
(${failures} failed)
<//>
`}
<//>
${!ready &&
html`
<${Box} marginLeft="2">
<${Transform}
transform=${(children) => terminalLink(children, authUrl)}
>
<${Text}>Login to fetch data<//>
<//>
<//>
`}
<//>
<${Newline} />
<${Box} flexDirection="column">
<${Box} marginBottom="1">
<${Text} bold>
Files (${handles.length - pending}/${handles.length})
<//>
<//>
${handles.map(
(file) =>
html`<${File}
key=${file.fd}
...${{
file,
addTime,
setPending,
addSongs,
addUniqueSongs,
}}
/>`
)}
<//>
<//>
`;
};
render(html`<${Spotify} />`);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment