Skip to content

Instantly share code, notes, and snippets.

@OFRBG
Created July 24, 2024 13:13
Show Gist options
  • Select an option

  • Save OFRBG/c95fa7d7ddf3f2bb6e9c35f44fd391dc to your computer and use it in GitHub Desktop.

Select an option

Save OFRBG/c95fa7d7ddf3f2bb6e9c35f44fd391dc to your computer and use it in GitHub Desktop.

Revisions

  1. OFRBG created this gist Jul 24, 2024.
    491 changes: 491 additions & 0 deletions spotify.mjs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,491 @@
    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} />`);