- Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { postSpotifyDownload, type InputType } from "../endpoints/spotify-download_POST.schema";
|
|
import {
|
|
getSpotifyDownloadStatus,
|
|
spotifyDownloadFileUrl,
|
|
} from "../endpoints/spotify-download-status_GET.schema";
|
|
import { getSpotifySession } from "../endpoints/spotify-session_GET.schema";
|
|
import { getDesktopBridge } from "./desktopBridge";
|
|
|
|
const POLL_INTERVAL_MS = 1500;
|
|
|
|
let canDownloadPromise: Promise<boolean> | undefined;
|
|
function loadCanDownload() {
|
|
canDownloadPromise ??= getSpotifySession()
|
|
.then((session) => {
|
|
const allowed = Boolean(session.connected && session.canDownload);
|
|
if (!allowed) canDownloadPromise = undefined;
|
|
return allowed;
|
|
})
|
|
.catch(() => {
|
|
canDownloadPromise = undefined;
|
|
return false;
|
|
});
|
|
return canDownloadPromise;
|
|
}
|
|
|
|
function saveFromServer(jobId: string) {
|
|
const anchor = document.createElement("a");
|
|
anchor.href = spotifyDownloadFileUrl(jobId);
|
|
anchor.rel = "noopener";
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
}
|
|
|
|
export function useSpotifyDownload() {
|
|
const [busyKeys, setBusyKeys] = useState<Set<string>>(new Set());
|
|
const [canDownload, setCanDownload] = useState(false);
|
|
const mounted = useRef(true);
|
|
|
|
useEffect(() => {
|
|
mounted.current = true;
|
|
loadCanDownload().then((allowed) => {
|
|
if (mounted.current) setCanDownload(allowed);
|
|
});
|
|
return () => {
|
|
mounted.current = false;
|
|
};
|
|
}, []);
|
|
|
|
function setBusy(key: string, busy: boolean) {
|
|
setBusyKeys((current) => {
|
|
const next = new Set(current);
|
|
if (busy) next.add(key);
|
|
else next.delete(key);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function startDownload(key: string, label: string, request: InputType) {
|
|
if (busyKeys.has(key)) return;
|
|
setBusy(key, true);
|
|
const toastId = toast.loading(`Preparing ${label}…`);
|
|
try {
|
|
const { jobId } = await postSpotifyDownload(request);
|
|
while (mounted.current) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS));
|
|
const status = await getSpotifyDownloadStatus(jobId);
|
|
if (status.status === "failed") throw new Error(status.error || "Download failed.");
|
|
if (status.status === "ready") {
|
|
const skipped = status.failed ? ` (${status.failed} track${status.failed === 1 ? "" : "s"} could not be found)` : "";
|
|
const desktop = getDesktopBridge();
|
|
if (desktop && status.savedPath) {
|
|
const savedPath = status.savedPath;
|
|
toast.success(`Saved ${label}${skipped}`, {
|
|
id: toastId,
|
|
description: savedPath,
|
|
action: { label: "Show in folder", onClick: () => desktop.showInFolder(savedPath) },
|
|
});
|
|
} else {
|
|
saveFromServer(jobId);
|
|
toast.success(`Downloading ${status.fileName}${skipped}`, { id: toastId, description: undefined });
|
|
}
|
|
return;
|
|
}
|
|
if (status.total > 1) {
|
|
toast.loading(`Downloading ${label}: ${status.done} of ${status.total}`, {
|
|
id: toastId,
|
|
description: status.currentTitle,
|
|
});
|
|
}
|
|
}
|
|
toast.dismiss(toastId);
|
|
} catch (reason) {
|
|
toast.error(reason instanceof Error ? reason.message : "Download failed.", { id: toastId });
|
|
} finally {
|
|
if (mounted.current) setBusy(key, false);
|
|
}
|
|
}
|
|
|
|
return { busyKeys, canDownload, startDownload };
|
|
}
|