Created
July 24, 2024 13:13
-
-
Save OFRBG/c95fa7d7ddf3f2bb6e9c35f44fd391dc to your computer and use it in GitHub Desktop.
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
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