Aurora SpotiRip desktop and web app

- 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
This commit is contained in:
2026-09-25 23:20:57 -05:00
commit 3178b8f4f7
204 changed files with 26045 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
import FFT from "fft.js";
import { decodeAudio } from "./audioProcessing";
export type AudioAnalysis = {
durationSeconds: number;
sampleRate: number;
channels: number;
peakDbfs: number;
rmsDbfs: number;
crestFactorDb: number;
estimatedCutoffHz: number;
qualityLabel: "Full Spectrum" | "Suspect Quality" | "Likely Transcode" | "Fake Lossless";
spectrogram: number[][];
};
function db(value: number) {
return 20 * Math.log10(Math.max(value, 1e-12));
}
function hann(index: number, size: number) {
return 0.5 * (1 - Math.cos((2 * Math.PI * index) / Math.max(1, size - 1)));
}
function monoAt(buffer: AudioBuffer, index: number) {
let value = 0;
for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) {
value += buffer.getChannelData(channel)[index] || 0;
}
return value / Math.max(1, buffer.numberOfChannels);
}
function qualityFromCutoff(cutoff: number, nyquist: number): AudioAnalysis["qualityLabel"] {
const effective = Math.min(cutoff, nyquist);
if (effective < 14_000) return "Fake Lossless";
if (effective < 17_000) return "Likely Transcode";
if (effective < 19_500 && nyquist >= 20_000) return "Suspect Quality";
return "Full Spectrum";
}
export async function analyzeAudioFile(file: File): Promise<AudioAnalysis> {
const buffer = await decodeAudio(file);
let peak = 0;
let sumSquares = 0;
let samples = 0;
const stride = Math.max(1, Math.floor(buffer.length / 2_000_000));
for (let index = 0; index < buffer.length; index += stride) {
const sample = monoAt(buffer, index);
const abs = Math.abs(sample);
if (abs > peak) peak = abs;
sumSquares += sample * sample;
samples += 1;
}
const rms = Math.sqrt(sumSquares / Math.max(1, samples));
const peakDbfs = db(peak);
const rmsDbfs = db(rms);
const fftSize = 4096;
const fft = new FFT(fftSize);
const input = new Array(fftSize).fill(0);
const output = fft.createComplexArray();
const slices = Math.min(96, Math.max(24, Math.floor(buffer.duration * 2)));
const bins = 128;
const spectrogram: number[][] = [];
const aggregate = new Float64Array(fftSize / 2);
for (let slice = 0; slice < slices; slice += 1) {
const center = Math.floor(((slice + 0.5) / slices) * buffer.length);
const start = Math.max(0, Math.min(buffer.length - fftSize, center - fftSize / 2));
for (let index = 0; index < fftSize; index += 1) {
input[index] = monoAt(buffer, start + index) * hann(index, fftSize);
}
fft.realTransform(output, input);
fft.completeSpectrum(output);
const magnitudes = new Float64Array(fftSize / 2);
let maxMagnitude = 1e-12;
for (let bin = 0; bin < fftSize / 2; bin += 1) {
const real = output[2 * bin];
const imag = output[2 * bin + 1];
const magnitude = Math.sqrt(real * real + imag * imag);
magnitudes[bin] = magnitude;
aggregate[bin] += magnitude;
if (magnitude > maxMagnitude) maxMagnitude = magnitude;
}
const row: number[] = [];
for (let displayBin = 0; displayBin < bins; displayBin += 1) {
const sourceStart = Math.floor((displayBin / bins) * magnitudes.length);
const sourceEnd = Math.max(
sourceStart + 1,
Math.floor(((displayBin + 1) / bins) * magnitudes.length),
);
let localMax = 0;
for (let source = sourceStart; source < sourceEnd; source += 1) {
localMax = Math.max(localMax, magnitudes[source]);
}
const relativeDb = db(localMax / maxMagnitude);
row.push(Math.max(0, Math.min(1, (relativeDb + 72) / 72)));
}
spectrogram.push(row);
}
let aggregatePeak = 1e-12;
for (const value of aggregate) aggregatePeak = Math.max(aggregatePeak, value);
let cutoffBin = 0;
for (let bin = 1; bin < aggregate.length; bin += 1) {
const relativeDb = db(aggregate[bin] / aggregatePeak);
if (relativeDb > -48) cutoffBin = bin;
}
const estimatedCutoffHz = Math.min(
buffer.sampleRate / 2,
(cutoffBin * buffer.sampleRate) / fftSize,
);
return {
durationSeconds: buffer.duration,
sampleRate: buffer.sampleRate,
channels: buffer.numberOfChannels,
peakDbfs,
rmsDbfs,
crestFactorDb: peakDbfs - rmsDbfs,
estimatedCutoffHz,
qualityLabel: qualityFromCutoff(estimatedCutoffHz, buffer.sampleRate / 2),
spectrogram,
};
}
+149
View File
@@ -0,0 +1,149 @@
import {
ALL_FORMATS,
BlobSource,
BufferTarget,
canEncodeAudio,
Conversion,
Input,
Mp3OutputFormat,
Output,
Quality,
} from "mediabunny";
import { registerMp3Encoder } from "@mediabunny/mp3-encoder";
type DecodedAudio = {
sampleRate: number;
channelCount: number;
channelData: Float32Array[];
};
export async function decodeAudio(file: File): Promise<AudioBuffer> {
const AudioContextCtor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextCtor) throw new Error("This browser does not support local audio decoding.");
const context = new AudioContextCtor();
try {
const data = await file.arrayBuffer();
return await context.decodeAudioData(data.slice(0));
} finally {
await context.close();
}
}
function clamp16(value: number) {
const sample = Math.max(-1, Math.min(1, value));
return sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function makeWavBlob(audio: DecodedAudio) {
const channels = Math.min(audio.channelCount, 2);
const frameCount = audio.channelData[0]?.length || 0;
const bytesPerSample = 2;
const blockAlign = channels * bytesPerSample;
const dataSize = frameCount * blockAlign;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
writeAscii(view, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeAscii(view, 8, "WAVE");
writeAscii(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, audio.sampleRate, true);
view.setUint32(28, audio.sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true);
writeAscii(view, 36, "data");
view.setUint32(40, dataSize, true);
let offset = 44;
for (let frame = 0; frame < frameCount; frame += 1) {
for (let channel = 0; channel < channels; channel += 1) {
view.setInt16(offset, clamp16(audio.channelData[channel][frame] || 0), true);
offset += 2;
}
}
return new Blob([buffer], { type: "audio/wav" });
}
export async function createRecognitionSnippet(file: File) {
const decoded = await decodeAudio(file);
const duration = decoded.duration;
const snippetSeconds = Math.min(12, Math.max(4, duration));
const preferredStart = duration > 35 ? 20 : duration > 20 ? 8 : 0;
const startSeconds = Math.min(preferredStart, Math.max(0, duration - snippetSeconds));
const startFrame = Math.floor(startSeconds * decoded.sampleRate);
const snippetFrames = Math.min(
Math.floor(snippetSeconds * decoded.sampleRate),
Math.max(0, decoded.length - startFrame),
);
const channels = Math.min(decoded.numberOfChannels, 2);
const channelData: Float32Array[] = [];
for (let channel = 0; channel < channels; channel += 1) {
const source = decoded.getChannelData(channel);
channelData.push(source.slice(startFrame, startFrame + snippetFrames));
}
const blob = makeWavBlob({
sampleRate: decoded.sampleRate,
channelCount: channels,
channelData,
});
return new File([blob], "recognition-snippet.wav", {
type: "audio/wav",
lastModified: Date.now(),
});
}
export async function convertAudioFileToMp3(file: File, bitrateKbps = 192) {
if (file.type === "audio/mpeg" || file.name.toLowerCase().endsWith(".mp3")) {
return new Blob([await file.arrayBuffer()], { type: "audio/mpeg" });
}
if (!(await canEncodeAudio("mp3"))) {
registerMp3Encoder();
}
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
});
const target = new BufferTarget();
const output = new Output({
format: new Mp3OutputFormat(),
target,
});
const conversion = await Conversion.init({
input,
output,
tracks: "primary",
video: { discard: true },
audio: {
codec: "mp3",
quality: new Quality({ bitrate: bitrateKbps * 1000 }),
forceTranscode: true,
},
});
if (!conversion.isValid) {
const reasons = conversion.discardedTracks.map((entry) => entry.reason).join(", ");
throw new Error(
reasons
? `This audio file cannot be converted in this browser (${reasons}).`
: "This audio file cannot be converted in this browser.",
);
}
await conversion.execute();
if (!target.buffer) throw new Error("MP3 conversion produced no output.");
return new Blob([target.buffer], { type: "audio/mpeg" });
}
+10
View File
@@ -0,0 +1,10 @@
export type DesktopBridge = {
getDownloadFolder: () => Promise<string>;
chooseDownloadFolder: () => Promise<string | undefined>;
showInFolder: (path: string) => Promise<void>;
};
export function getDesktopBridge(): DesktopBridge | undefined {
if (typeof window === "undefined") return undefined;
return (window as typeof window & { spotiripDesktop?: DesktopBridge }).spotiripDesktop;
}
+60
View File
@@ -0,0 +1,60 @@
import type { WritableDirectoryHandle } from "./mp3Tags";
const DB_NAME = "aurora-spotirip";
const STORE_NAME = "handles";
const OUTPUT_KEY = "output-directory";
type PermissionedDirectoryHandle = WritableDirectoryHandle & {
queryPermission?: (options?: { mode?: "read" | "readwrite" }) => Promise<"granted" | "denied" | "prompt">;
requestPermission?: (options?: { mode?: "read" | "readwrite" }) => Promise<"granted" | "denied" | "prompt">;
};
function openDb() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Unable to open local folder settings."));
});
}
export async function saveOutputDirectory(handle: WritableDirectoryHandle) {
const db = await openDb();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(handle, OUTPUT_KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error("Unable to save output folder."));
});
} finally {
db.close();
}
}
export async function loadOutputDirectory(): Promise<WritableDirectoryHandle | undefined> {
if (typeof indexedDB === "undefined") return undefined;
const db = await openDb();
try {
return await new Promise<WritableDirectoryHandle | undefined>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const request = tx.objectStore(STORE_NAME).get(OUTPUT_KEY);
request.onsuccess = () => resolve(request.result as WritableDirectoryHandle | undefined);
request.onerror = () => reject(request.error || new Error("Unable to restore output folder."));
});
} finally {
db.close();
}
}
export async function ensureDirectoryWritePermission(handle: WritableDirectoryHandle) {
const permissioned = handle as PermissionedDirectoryHandle;
if (!permissioned.queryPermission || !permissioned.requestPermission) return true;
const current = await permissioned.queryPermission({ mode: "readwrite" });
if (current === "granted") return true;
return (await permissioned.requestPermission({ mode: "readwrite" })) === "granted";
}
+216
View File
@@ -0,0 +1,216 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { ensureSpotifyAccessToken } from "./spotifyServer";
const DESKTOP = process.env.SPOTIRIP_DESKTOP === "1";
const PYTHON = process.env.SPOTIRIP_PYTHON || "venv/bin/python";
const DOWNLOADER = process.env.SPOTIRIP_DOWNLOADER || "spotify_downloader.py";
const DOWNLOADER_BIN = process.env.SPOTIRIP_DOWNLOADER_BIN;
const EVENT_PREFIX = "@@SPOTIRIP ";
const MAX_RUNNING_JOBS = Number(process.env.SPOTIRIP_MAX_DOWNLOADS) || (DESKTOP ? 3 : 2);
const MAX_RUNNING_JOBS_PER_USER = DESKTOP ? MAX_RUNNING_JOBS : 1;
const FINISHED_JOB_TTL_MS = 30 * 60 * 1000;
export type DownloadKind = "track" | "playlist";
export type DownloadJob = {
id: string;
ownerId: string;
kind: DownloadKind;
status: "running" | "ready" | "failed";
done: number;
total: number;
failed: number;
currentTitle?: string;
error?: string;
filePath?: string;
fileName?: string;
savedPath?: string;
workDir: string;
isTempDir: boolean;
finishedAt?: number;
};
const jobs = new Map<string, DownloadJob>();
const userIdCache = new Map<string, { id: string; expiresAt: number }>();
function allowedUserIds() {
return (process.env.SPOTIRIP_DOWNLOAD_USERS || "*")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
export function isDownloadAllowed(spotifyUserId: string | undefined) {
if (!spotifyUserId) return false;
const allowed = allowedUserIds();
return allowed.includes("*") || allowed.includes(spotifyUserId);
}
async function spotifyUserId(accessToken: string) {
const cached = userIdCache.get(accessToken);
if (cached && cached.expiresAt > Date.now()) return cached.id;
const response = await fetch("https://api.spotify.com/v1/me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return undefined;
const profile = (await response.json()) as { id: string };
userIdCache.set(accessToken, { id: profile.id, expiresAt: Date.now() + 10 * 60 * 1000 });
return profile.id;
}
export async function requireDownloadAccess(request: Request) {
const session = await ensureSpotifyAccessToken(request);
if (!session.accessToken) {
return { error: jsonError("Connect Spotify before downloading.", 401) };
}
const userId = await spotifyUserId(session.accessToken);
if (!isDownloadAllowed(userId)) {
return { error: jsonError("Downloads are not enabled for this Spotify account.", 403) };
}
return { userId: userId!, setCookies: session.setCookies };
}
export function jsonError(message: string, status: number) {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
function removeJob(job: DownloadJob) {
jobs.delete(job.id);
if (job.isTempDir) rmSync(job.workDir, { recursive: true, force: true });
}
function safeFolderName(name: string) {
const cleaned = name.replace(/[\\/:*?"<>|\x00-\x1f]+/g, " ").replace(/\s+/g, " ").trim().replace(/^\.+|\.+$/g, "");
return cleaned.slice(0, 150) || "Spotify playlist";
}
function desktopOutputDir(kind: DownloadKind, name?: string) {
const folder = process.env.SPOTIRIP_DOWNLOAD_FOLDER;
if (!folder) throw new Error("Choose a download folder in Settings first.");
const dir = kind === "playlist" ? join(folder, safeFolderName(name || "")) : folder;
mkdirSync(dir, { recursive: true });
return dir;
}
const sweeper = setInterval(() => {
const cutoff = Date.now() - FINISHED_JOB_TTL_MS;
for (const job of jobs.values()) {
if (job.finishedAt && job.finishedAt < cutoff) removeJob(job);
}
}, 60 * 1000);
sweeper.unref();
export function startDownloadJob(options: { ownerId: string; kind: DownloadKind; urls: string[]; name?: string }) {
const running = [...jobs.values()].filter((job) => job.status === "running");
if (running.filter((job) => job.ownerId === options.ownerId).length >= MAX_RUNNING_JOBS_PER_USER) {
throw new Error("You already have a download in progress. Wait for it to finish first.");
}
if (running.length >= MAX_RUNNING_JOBS) {
throw new Error("The server is busy with other downloads. Try again in a few minutes.");
}
const workDir = DESKTOP ? desktopOutputDir(options.kind, options.name) : mkdtempSync(join(tmpdir(), "spotirip-"));
const job: DownloadJob = {
id: randomUUID(),
ownerId: options.ownerId,
kind: options.kind,
status: "running",
done: 0,
total: 0,
failed: 0,
workDir,
isTempDir: !DESKTOP,
};
jobs.set(job.id, job);
const args = ["--urls-stdin", "--output-dir", workDir, "--json"];
if (options.kind === "playlist" && !DESKTOP) args.push("--zip", "--zip-name", options.name || "");
const child = spawn(DOWNLOADER_BIN || resolve(PYTHON), DOWNLOADER_BIN ? args : [resolve(DOWNLOADER), ...args], {
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
SPOTIRIP_JS_RUNTIME: process.execPath,
...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
},
});
child.stdin.on("error", () => undefined);
child.stdin.end(options.urls.join("\n") + "\n");
let buffered = "";
let stderrTail = "";
let outputTail = "";
child.stdout.on("data", (chunk: Buffer) => {
buffered += chunk.toString("utf8");
const lines = buffered.split("\n");
buffered = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith(EVENT_PREFIX)) {
outputTail = (outputTail + line + "\n").slice(-2000);
continue;
}
try {
handleEvent(job, JSON.parse(line.slice(EVENT_PREFIX.length)));
} catch {
}
}
});
child.stderr.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000);
});
child.on("error", (error) => finishWithError(job, `Could not start the downloader: ${error.message}`));
child.on("close", (code) => {
if (code !== 0) console.error(`spotify_downloader.py exited with ${code}:\n${outputTail}${stderrTail}`);
if (job.status !== "running") return;
finishWithError(job, "The downloader stopped unexpectedly.");
});
return job;
}
function handleEvent(job: DownloadJob, event: Record<string, unknown>) {
if (event.event === "found") {
job.total = Number(event.total) || 0;
} else if (event.event === "progress") {
job.done = Number(event.done) || job.done;
job.total = Number(event.total) || job.total;
job.currentTitle = String(event.title || "");
if (event.ok === false) {
job.failed += 1;
console.warn(`Download ${job.id}: could not download "${event.title}" by ${event.artist}`);
}
} else if (event.event === "complete") {
job.status = "ready";
job.filePath = String(event.file);
job.fileName = basename(job.filePath);
job.savedPath = DESKTOP && job.kind === "playlist" ? job.workDir : job.filePath;
job.finishedAt = Date.now();
} else if (event.event === "error") {
finishWithError(job, String(event.message || "Download failed."));
}
}
function finishWithError(job: DownloadJob, message: string) {
if (job.status !== "running") return;
job.status = "failed";
job.error = message;
console.error(`Download ${job.id} failed after ${job.done}/${job.total} tracks: ${message}`);
job.finishedAt = Date.now();
if (job.isTempDir) rmSync(job.workDir, { recursive: true, force: true });
}
export function getDownloadJob(id: string, ownerId: string) {
const job = jobs.get(id);
return job && job.ownerId === ownerId ? job : undefined;
}
export function finishDownloadJob(job: DownloadJob) {
removeJob(job);
}
+38
View File
@@ -0,0 +1,38 @@
export type LibraryHistoryEntry = {
id: string;
timestamp: number;
kind: "playlist" | "identify" | "convert";
title: string;
trackCount?: number;
artworkUrl?: string;
sourceUrl?: string;
};
const KEY = "aurora-spotirip-history-v1";
export function loadLibraryHistory(): LibraryHistoryEntry[] {
if (typeof window === "undefined") return [];
try {
const parsed = JSON.parse(window.localStorage.getItem(KEY) || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function addLibraryHistory(entry: Omit<LibraryHistoryEntry, "id" | "timestamp">) {
const current = loadLibraryHistory();
const next: LibraryHistoryEntry[] = [
{
...entry,
id: crypto.randomUUID(),
timestamp: Date.now(),
},
...current,
].slice(0, 200);
window.localStorage.setItem(KEY, JSON.stringify(next));
}
export function clearLibraryHistory() {
window.localStorage.removeItem(KEY);
}
+41
View File
@@ -0,0 +1,41 @@
export type LibraryIdentityInput = {
isrc?: string;
title?: string;
artist?: string;
album?: string;
};
function normalize(value?: string) {
return (value || "")
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
export function makeLibraryIdentity(input: LibraryIdentityInput) {
if (input.isrc?.trim()) return `isrc:${input.isrc.trim().toUpperCase()}`;
return [
"meta",
normalize(input.artist),
normalize(input.title),
normalize(input.album),
].join(":");
}
export function findDuplicateIdentities(items: LibraryIdentityInput[]) {
const seen = new Map<string, number>();
const duplicates = new Set<number>();
items.forEach((item, index) => {
const identity = makeLibraryIdentity(item);
const first = seen.get(identity);
if (typeof first === "number") {
duplicates.add(first);
duplicates.add(index);
} else {
seen.set(identity, index);
}
});
return duplicates;
}
+123
View File
@@ -0,0 +1,123 @@
export type LibraryTrackRecord = {
id: string;
isrc?: string;
title: string;
artist: string;
album?: string;
albumArtist?: string;
savedPath?: string;
artworkUrl?: string;
source?: string;
addedAt: number;
};
const DB_NAME = "aurora-spotirip-library";
const TRACK_STORE = "tracks";
const HISTORY_STORE = "history";
function normalize(value: string) {
return value
.normalize("NFKD")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
export function trackIdentity(track: {
isrc?: string;
artist: string;
title: string;
}) {
return track.isrc?.trim().toUpperCase() ||
`${normalize(track.artist)}::${normalize(track.title)}`;
}
function openDb() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(TRACK_STORE)) {
db.createObjectStore(TRACK_STORE, { keyPath: "id" });
}
if (!db.objectStoreNames.contains(HISTORY_STORE)) {
db.createObjectStore(HISTORY_STORE, { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Unable to open library index."));
});
}
export async function findLibraryDuplicate(track: {
isrc?: string;
artist: string;
title: string;
}) {
if (typeof indexedDB === "undefined") return undefined;
const db = await openDb();
try {
const id = trackIdentity(track);
return await new Promise<LibraryTrackRecord | undefined>((resolve, reject) => {
const tx = db.transaction(TRACK_STORE, "readonly");
const request = tx.objectStore(TRACK_STORE).get(id);
request.onsuccess = () => resolve(request.result as LibraryTrackRecord | undefined);
request.onerror = () => reject(request.error);
});
} finally {
db.close();
}
}
export async function rememberLibraryTrack(
track: Omit<LibraryTrackRecord, "id" | "addedAt">,
) {
const db = await openDb();
try {
const record: LibraryTrackRecord = {
...track,
id: trackIdentity(track),
addedAt: Date.now(),
};
await new Promise<void>((resolve, reject) => {
const tx = db.transaction([TRACK_STORE, HISTORY_STORE], "readwrite");
tx.objectStore(TRACK_STORE).put(record);
tx.objectStore(HISTORY_STORE).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error("Unable to update library history."));
});
return record;
} finally {
db.close();
}
}
export async function listLibraryHistory() {
if (typeof indexedDB === "undefined") return [] as LibraryTrackRecord[];
const db = await openDb();
try {
const records = await new Promise<LibraryTrackRecord[]>((resolve, reject) => {
const tx = db.transaction(HISTORY_STORE, "readonly");
const request = tx.objectStore(HISTORY_STORE).getAll();
request.onsuccess = () => resolve((request.result || []) as LibraryTrackRecord[]);
request.onerror = () => reject(request.error);
});
return records.sort((a, b) => b.addedAt - a.addedAt);
} finally {
db.close();
}
}
export async function clearLibraryHistory() {
const db = await openDb();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(HISTORY_STORE, "readwrite");
tx.objectStore(HISTORY_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
}
+112
View File
@@ -0,0 +1,112 @@
import {
formatFolderPath,
formatMp3Filename,
sanitizeFilename,
type Mp3Metadata,
} from "./mp3Tags";
import type { ScannedLibraryTrack } from "./libraryScanner";
import type { SpotiRipSettings } from "./spotiripSettings";
type OrganizeDirectoryHandle = FileSystemDirectoryHandle & {
getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<FileSystemDirectoryHandle>;
getFileHandle(name: string, options?: { create?: boolean }): Promise<FileSystemFileHandle>;
};
function segment(value: string | undefined, fallback: string) {
return sanitizeFilename(value?.trim() || fallback).replace(/[./\\]+/g, "_");
}
async function ensureDirectory(root: FileSystemDirectoryHandle, names: string[]) {
let current = root as OrganizeDirectoryHandle;
for (const name of names) {
current = (await current.getDirectoryHandle(name, { create: true })) as OrganizeDirectoryHandle;
}
return current;
}
async function exists(directory: FileSystemDirectoryHandle, name: string) {
try {
await (directory as OrganizeDirectoryHandle).getFileHandle(name);
return true;
} catch {
return false;
}
}
async function uniqueName(directory: FileSystemDirectoryHandle, desired: string) {
if (!(await exists(directory, desired))) return desired;
const dot = desired.lastIndexOf(".");
const base = dot > 0 ? desired.slice(0, dot) : desired;
const ext = dot > 0 ? desired.slice(dot) : "";
for (let index = 2; index < 10000; index += 1) {
const candidate = base + " (" + index + ")" + ext;
if (!(await exists(directory, candidate))) return candidate;
}
throw new Error("Could not create a unique library filename.");
}
function metadataForTrack(
track: ScannedLibraryTrack,
settings?: Pick<SpotiRipSettings, "multiDiscMode">,
): Mp3Metadata {
const trackLabel = track.trackNumber
? settings?.multiDiscMode === "offset" && track.discNumber
? String(track.discNumber * 100 + track.trackNumber)
: settings?.multiDiscMode === "disc-prefix" && track.discNumber
? String(track.discNumber) + "-" + String(track.trackNumber).padStart(2, "0")
: String(track.trackNumber).padStart(2, "0")
: "";
return {
title: track.title || "Unknown Track",
artist: track.artist || "Unknown Artist",
album: track.album,
albumArtist: track.albumArtist,
year: track.date?.slice(0, 4),
trackNumber: track.trackNumber,
trackLabel,
discNumber: track.discNumber,
genre: track.genre,
isrc: track.isrc,
};
}
export function suggestedLibraryPath(
track: ScannedLibraryTrack,
settings?: Pick<
SpotiRipSettings,
"folderTemplate" | "filenameTemplate" | "multiDiscMode"
>,
) {
const metadata = metadataForTrack(track, settings);
const folders = formatFolderPath(
settings?.folderTemplate || "{album_artist}/{year} - {album}",
metadata,
);
const extension = track.file.name.includes(".")
? track.file.name.slice(track.file.name.lastIndexOf(".")).toLowerCase()
: "";
const formatted = formatMp3Filename(
settings?.filenameTemplate || "{artist} - {title}",
metadata,
).replace(/\.mp3$/i, "");
const filename = segment(formatted, "Unknown Track") + extension;
return { folders, filename };
}
export async function copyOrganizedTrack(
root: FileSystemDirectoryHandle,
track: ScannedLibraryTrack,
settings?: Pick<
SpotiRipSettings,
"folderTemplate" | "filenameTemplate" | "multiDiscMode"
>,
) {
const target = suggestedLibraryPath(track, settings);
const directory = await ensureDirectory(root, target.folders);
const filename = await uniqueName(directory, target.filename);
const handle = await (directory as OrganizeDirectoryHandle).getFileHandle(filename, { create: true });
const writable = await handle.createWritable();
await writable.write(track.file);
await writable.close();
return [...target.folders, filename].join("/");
}
+161
View File
@@ -0,0 +1,161 @@
import { ALL_FORMATS, BlobSource, Input } from "mediabunny";
export type ScannedLibraryTrack = {
id: string;
file: File;
relativePath: string;
title: string;
artist: string;
album?: string;
albumArtist?: string;
trackNumber?: number;
discNumber?: number;
genre?: string;
date?: string;
lyricsPresent: boolean;
artworkPresent: boolean;
isrc?: string;
codec?: string;
sampleRate?: number;
channels?: number;
bitrateKbps?: number;
missing: string[];
duplicateOf?: string;
};
type DirectoryHandle = FileSystemDirectoryHandle & {
values(): AsyncIterableIterator<FileSystemFileHandle | FileSystemDirectoryHandle>;
};
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "flac", "m4a", "aac", "ogg", "opus"]);
function extension(name: string) {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
function cleanGuess(name: string) {
return name.replace(/\.[^.]+$/, "").replace(/[_]+/g, " ").replace(/\s+/g, " ").trim();
}
function rawText(raw: Record<string, unknown> | undefined, key: string) {
const value = raw?.[key];
if (typeof value === "string") return value;
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
return undefined;
}
async function inspectFile(file: File, relativePath: string): Promise<ScannedLibraryTrack> {
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
try {
const [tags, audioTrack] = await Promise.all([
input.getMetadataTags(),
input.getPrimaryAudioTrack(),
]);
const [codec, sampleRate, channels, averageBitrate] = audioTrack
? await Promise.all([
audioTrack.getCodec().catch(() => null),
audioTrack.getSampleRate().catch(() => undefined),
audioTrack.getNumberOfChannels().catch(() => undefined),
audioTrack.getAverageBitrate().catch(() => null),
])
: [null, undefined, undefined, null];
const title = tags.title?.trim() || cleanGuess(file.name);
const artist = tags.artist?.trim() || "";
const album = tags.album?.trim() || undefined;
const albumArtist = tags.albumArtist?.trim() || undefined;
const raw = tags.raw as Record<string, unknown> | undefined;
const isrc = rawText(raw, "TSRC") || rawText(raw, "ISRC") || rawText(raw, "isrc");
const missing: string[] = [];
if (!tags.title?.trim()) missing.push("title");
if (!artist) missing.push("artist");
if (!album) missing.push("album");
if (!tags.trackNumber) missing.push("track");
if (!tags.genre) missing.push("genre");
if (!tags.images?.length) missing.push("artwork");
return {
id: relativePath + ":" + file.size + ":" + file.lastModified,
file,
relativePath,
title,
artist,
album,
albumArtist,
trackNumber: tags.trackNumber,
discNumber: tags.discNumber,
genre: tags.genre,
date: tags.date?.toISOString().slice(0, 10),
lyricsPresent: Boolean(tags.lyrics?.trim()),
artworkPresent: Boolean(tags.images?.length),
isrc,
codec: codec || undefined,
sampleRate,
channels,
bitrateKbps: averageBitrate ? Math.round(averageBitrate / 1000) : undefined,
missing,
};
} finally {
input.dispose();
}
}
export async function collectAudioFiles(
directory: FileSystemDirectoryHandle,
prefix = "",
): Promise<Array<{ file: File; relativePath: string }>> {
const files: Array<{ file: File; relativePath: string }> = [];
for await (const entry of (directory as DirectoryHandle).values()) {
const relativePath = prefix ? prefix + "/" + entry.name : entry.name;
if (entry.kind === "directory") {
files.push(...(await collectAudioFiles(entry as FileSystemDirectoryHandle, relativePath)));
continue;
}
if (!AUDIO_EXTENSIONS.has(extension(entry.name))) continue;
files.push({
file: await (entry as FileSystemFileHandle).getFile(),
relativePath,
});
}
return files;
}
export async function scanAudioLibrary(
directory: FileSystemDirectoryHandle,
onProgress?: (completed: number, total: number) => void,
) {
const candidates = await collectAudioFiles(directory);
const tracks: ScannedLibraryTrack[] = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
try {
tracks.push(await inspectFile(candidate.file, candidate.relativePath));
} catch {
tracks.push({
id: candidate.relativePath + ":" + candidate.file.size + ":" + candidate.file.lastModified,
file: candidate.file,
relativePath: candidate.relativePath,
title: cleanGuess(candidate.file.name),
artist: "",
lyricsPresent: false,
artworkPresent: false,
missing: ["unreadable metadata"],
});
}
onProgress?.(index + 1, candidates.length);
}
const identities = new Map<string, ScannedLibraryTrack>();
for (const track of tracks) {
const identity = track.isrc?.trim().toUpperCase() ||
track.artist.toLowerCase().trim() + "::" +
track.title.toLowerCase().trim() + "::" +
(track.album || "").toLowerCase().trim();
const existing = identities.get(identity);
if (existing) track.duplicateOf = existing.relativePath;
else identities.set(identity, track);
}
return tracks;
}
+188
View File
@@ -0,0 +1,188 @@
import { ID3Writer } from "browser-id3-writer";
import { convertAudioFileToMp3 } from "./audioProcessing";
export type Mp3Metadata = {
title: string;
artist: string;
album?: string;
albumArtist?: string;
year?: string;
trackNumber?: number;
trackLabel?: string;
discNumber?: number;
genre?: string;
composer?: string;
isrc?: string;
lyrics?: string;
artworkUrl?: string;
};
export function sanitizeFilename(value: string) {
return value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
.trim();
}
export function formatMp3Filename(template: string, metadata: Mp3Metadata) {
const replacements: Record<string, string> = {
"{artist}": metadata.artist || "Unknown Artist",
"{album_artist}": metadata.albumArtist || metadata.artist || "Unknown Artist",
"{title}": metadata.title || "Unknown Title",
"{album}": metadata.album || "Unknown Album",
"{year}": metadata.year || "",
"{track}": metadata.trackLabel || (metadata.trackNumber ? String(metadata.trackNumber).padStart(2, "0") : ""),
"{disc}": metadata.discNumber ? String(metadata.discNumber) : "",
"{genre}": metadata.genre || "",
"{composer}": metadata.composer || "",
"{isrc}": metadata.isrc || "",
};
let value = template || "{artist} - {title}";
for (const [token, replacement] of Object.entries(replacements)) {
value = value.split(token).join(replacement);
}
value = sanitizeFilename(value) || "Unknown Track";
return value.toLowerCase().endsWith(".mp3") ? value : value + ".mp3";
}
export function formatFolderPath(template: string, metadata: Mp3Metadata) {
const replacements: Record<string, string> = {
"{artist}": metadata.artist || "Unknown Artist",
"{album_artist}": metadata.albumArtist || metadata.artist || "Unknown Artist",
"{title}": metadata.title || "Unknown Title",
"{album}": metadata.album || "Unknown Album",
"{year}": metadata.year || "Unknown Year",
"{track}": metadata.trackNumber ? String(metadata.trackNumber).padStart(2, "0") : "",
"{disc}": metadata.discNumber ? String(metadata.discNumber) : "",
"{genre}": metadata.genre || "Unknown Genre",
"{composer}": metadata.composer || "",
"{isrc}": metadata.isrc || "",
};
let value = template || "{album_artist}/{year} - {album}";
for (const [token, replacement] of Object.entries(replacements)) {
value = value.split(token).join(replacement);
}
return value
.split(/[\\/]+/)
.map((part) => sanitizeFilename(part))
.filter(Boolean);
}
export async function writeMp3Tags(file: File, metadata: Mp3Metadata, bitrateKbps = 192) {
const mp3Blob = await convertAudioFileToMp3(file, bitrateKbps);
const arrayBuffer = await mp3Blob.arrayBuffer();
const writer = new ID3Writer(arrayBuffer);
writer.setFrame("TIT2", metadata.title);
writer.setFrame("TPE1", [metadata.artist]);
if (metadata.album) writer.setFrame("TALB", metadata.album);
if (metadata.albumArtist) writer.setFrame("TPE2", metadata.albumArtist);
if (metadata.year) writer.setFrame("TYER" as any, Number(metadata.year.slice(0, 4)));
if (metadata.trackNumber) writer.setFrame("TRCK", String(metadata.trackNumber));
if (metadata.discNumber) writer.setFrame("TPOS", String(metadata.discNumber));
if (metadata.genre) writer.setFrame("TCON", [metadata.genre]);
if (metadata.composer) writer.setFrame("TCOM", [metadata.composer]);
if (metadata.isrc) writer.setFrame("TSRC", metadata.isrc);
if (metadata.lyrics) {
writer.setFrame("USLT", {
description: "",
lyrics: metadata.lyrics,
language: "eng",
});
}
if (metadata.artworkUrl) {
try {
const response = await fetch(metadata.artworkUrl);
if (response.ok) {
const artwork = await response.arrayBuffer();
writer.setFrame("APIC", {
type: 3,
data: artwork,
description: "Cover",
useUnicodeEncoding: false,
});
}
} catch {
}
}
writer.addTag();
return writer.getBlob();
}
type WritableFileHandle = {
createWritable(): Promise<{
write(data: Blob): Promise<void>;
close(): Promise<void>;
}>;
};
export type WritableDirectoryHandle = {
name: string;
getFileHandle(name: string, options?: { create?: boolean }): Promise<WritableFileHandle>;
getDirectoryHandle?(
name: string,
options?: { create?: boolean },
): Promise<WritableDirectoryHandle>;
};
async function fileExists(directory: WritableDirectoryHandle, name: string) {
try {
await directory.getFileHandle(name);
return true;
} catch {
return false;
}
}
export async function uniqueFilename(directory: WritableDirectoryHandle, desired: string) {
if (!(await fileExists(directory, desired))) return desired;
const dot = desired.toLowerCase().endsWith(".mp3") ? desired.length - 4 : desired.length;
const base = desired.slice(0, dot);
const ext = desired.slice(dot) || ".mp3";
for (let index = 2; index < 10_000; index += 1) {
const candidate = `${base} (${index})${ext}`;
if (!(await fileExists(directory, candidate))) return candidate;
}
throw new Error("Could not create a unique filename.");
}
export async function saveBlobToDirectory(
directory: WritableDirectoryHandle,
filename: string,
blob: Blob,
) {
const safeName = await uniqueFilename(directory, filename);
const handle = await directory.getFileHandle(safeName, { create: true });
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return safeName;
}
export async function saveBlobToOrganizedPath(
root: WritableDirectoryHandle,
folders: string[],
filename: string,
blob: Blob,
) {
let current = root;
for (const folder of folders) {
if (!current.getDirectoryHandle) {
return saveBlobToDirectory(root, filename, blob);
}
current = await current.getDirectoryHandle(folder, { create: true });
}
const savedName = await saveBlobToDirectory(current, filename, blob);
return folders.length ? `${folders.join("/")}/${savedName}` : savedName;
}
export function downloadBlob(filename: string, blob: Blob) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
}
+147
View File
@@ -0,0 +1,147 @@
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
const configuredBaseUrl = (
process.env as Record<string, string | undefined>
).SPOTIRIP_BASE_URL?.trim().replace(/\/+$/, "");
export const SPOTIRIP_BASE_URL =
configuredBaseUrl || "https://spotirip.auroradragon.dev";
export const SPOTIFY_REDIRECT_URI = SPOTIRIP_BASE_URL + "/spotify/callback";
type StoredSpotifySession = {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
};
type TokenResponse = {
access_token: string;
token_type: string;
scope?: string;
expires_in: number;
refresh_token?: string;
};
export function parseCookies(request: Request) {
const raw = request.headers.get("cookie") || "";
const cookies: Record<string, string> = {};
for (const part of raw.split(";")) {
const index = part.indexOf("=");
if (index < 0) continue;
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key) cookies[key] = decodeURIComponent(value);
}
return cookies;
}
export function readSpotifySession(request: Request): StoredSpotifySession {
const cookies = parseCookies(request);
const expiresAt = Number(cookies.spotirip_spotify_expires || 0);
return {
accessToken: cookies.spotirip_spotify_access,
refreshToken: cookies.spotirip_spotify_refresh,
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
};
}
export function makeCookie(
name: string,
value: string,
options: { maxAge?: number; httpOnly?: boolean } = {},
) {
const segments = [
`${name}=${encodeURIComponent(value)}`,
"Path=/",
"Secure",
"SameSite=Lax",
];
if (options.httpOnly !== false) segments.push("HttpOnly");
if (typeof options.maxAge === "number") segments.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
return segments.join("; ");
}
export function clearCookie(name: string) {
return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
}
function spotifyBasicAuth() {
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("Spotify credentials are not configured.");
}
return "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
}
export async function exchangeSpotifyCode(code: string) {
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
Authorization: spotifyBasicAuth(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: SPOTIFY_REDIRECT_URI,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`Spotify token exchange failed (${response.status}): ${message.slice(0, 300)}`);
}
return (await response.json()) as TokenResponse;
}
export async function refreshSpotifyToken(refreshToken: string) {
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
Authorization: spotifyBasicAuth(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`Spotify token refresh failed (${response.status}): ${message.slice(0, 300)}`);
}
return (await response.json()) as TokenResponse;
}
export async function ensureSpotifyAccessToken(request: Request) {
const current = readSpotifySession(request);
const now = Date.now();
if (current.accessToken && current.expiresAt && current.expiresAt > now + 60_000) {
return { accessToken: current.accessToken, setCookies: [] as string[] };
}
if (!current.refreshToken) {
return { accessToken: null, setCookies: [] as string[] };
}
const refreshed = await refreshSpotifyToken(current.refreshToken);
const expiresAt = Date.now() + refreshed.expires_in * 1000;
const setCookies = [
makeCookie("spotirip_spotify_access", refreshed.access_token, { maxAge: refreshed.expires_in }),
makeCookie("spotirip_spotify_expires", String(expiresAt), { maxAge: refreshed.expires_in }),
];
if (refreshed.refresh_token) {
setCookies.push(
makeCookie("spotirip_spotify_refresh", refreshed.refresh_token, { maxAge: 60 * 60 * 24 * 30 }),
);
}
return { accessToken: refreshed.access_token, setCookies };
}
export function appendSetCookies(headers: Headers, cookies: string[]) {
for (const cookie of cookies) headers.append("Set-Cookie", cookie);
}
+71
View File
@@ -0,0 +1,71 @@
export type SpotiRipSettings = {
filenameTemplate: string;
folderTemplate: string;
multiDiscMode: "disc-prefix" | "offset" | "track-only";
mp3Bitrate: 128 | 192 | 256 | 320;
embedArtwork: boolean;
autoSelectMatches: boolean;
smartDedup: boolean;
};
export const DEFAULT_SPOTIRIP_SETTINGS: SpotiRipSettings = {
filenameTemplate: "{artist} - {title}",
folderTemplate: "{album_artist}/{year} - {album}",
multiDiscMode: "disc-prefix",
mp3Bitrate: 192,
embedArtwork: true,
autoSelectMatches: true,
smartDedup: true,
};
const STORAGE_KEY = "aurora-spotirip-settings-v1";
export function loadSpotiRipSettings(): SpotiRipSettings {
if (typeof window === "undefined") return DEFAULT_SPOTIRIP_SETTINGS;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (!stored) return DEFAULT_SPOTIRIP_SETTINGS;
const parsed = JSON.parse(stored) as Partial<SpotiRipSettings>;
return {
filenameTemplate:
typeof parsed.filenameTemplate === "string" && parsed.filenameTemplate.trim()
? parsed.filenameTemplate
: DEFAULT_SPOTIRIP_SETTINGS.filenameTemplate,
folderTemplate:
typeof parsed.folderTemplate === "string" && parsed.folderTemplate.trim()
? parsed.folderTemplate
: DEFAULT_SPOTIRIP_SETTINGS.folderTemplate,
multiDiscMode:
parsed.multiDiscMode === "disc-prefix" ||
parsed.multiDiscMode === "offset" ||
parsed.multiDiscMode === "track-only"
? parsed.multiDiscMode
: DEFAULT_SPOTIRIP_SETTINGS.multiDiscMode,
mp3Bitrate:
parsed.mp3Bitrate === 128 ||
parsed.mp3Bitrate === 192 ||
parsed.mp3Bitrate === 256 ||
parsed.mp3Bitrate === 320
? parsed.mp3Bitrate
: DEFAULT_SPOTIRIP_SETTINGS.mp3Bitrate,
embedArtwork:
typeof parsed.embedArtwork === "boolean"
? parsed.embedArtwork
: DEFAULT_SPOTIRIP_SETTINGS.embedArtwork,
autoSelectMatches:
typeof parsed.autoSelectMatches === "boolean"
? parsed.autoSelectMatches
: DEFAULT_SPOTIRIP_SETTINGS.autoSelectMatches,
smartDedup:
typeof parsed.smartDedup === "boolean"
? parsed.smartDedup
: DEFAULT_SPOTIRIP_SETTINGS.smartDedup,
};
} catch {
return DEFAULT_SPOTIRIP_SETTINGS;
}
}
export function saveSpotiRipSettings(settings: SpotiRipSettings) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
+118
View File
@@ -0,0 +1,118 @@
import { switchToDarkMode, switchToLightMode, switchToAutoMode, getCurrentThemeMode } from "./themeMode";
function createMediaQueryList(initialMatches: boolean, query = "(prefers-color-scheme: dark)") {
let currentMatches = initialMatches;
const mql: Partial<MediaQueryList> = {
get matches() {
return currentMatches;
},
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
};
return Object.assign(mql, {
setMatches(newMatches: boolean) {
currentMatches = newMatches;
},
}) as MediaQueryList & { setMatches: (matches: boolean) => void };
}
describe("themeMode helper", () => {
let originalMatchMedia: typeof window.matchMedia;
beforeAll(() => {
originalMatchMedia = window.matchMedia;
});
afterAll(() => {
window.matchMedia = originalMatchMedia;
});
beforeEach(() => {
document.body.className = "";
});
it("should add the 'dark' class when switching to dark mode", () => {
switchToDarkMode();
expect(document.body.classList.contains("dark")).toBeTrue();
});
it("should remove the 'dark' class when switching to light mode", () => {
document.body.classList.add("dark");
switchToLightMode();
expect(document.body.classList.contains("dark")).toBeFalse();
});
it("should apply dark mode automatically when user prefers dark", () => {
window.matchMedia = (query: string): MediaQueryList =>
createMediaQueryList(true, query);
switchToAutoMode();
expect(document.body.classList.contains("dark")).toBeTrue();
});
it("should apply light mode automatically when user does not prefer dark", () => {
document.body.classList.add("dark");
window.matchMedia = (query: string): MediaQueryList =>
createMediaQueryList(false, query);
switchToAutoMode();
expect(document.body.classList.contains("dark")).toBeFalse();
});
it("should update theme when system preference changes in auto mode upon re-calling switchToAutoMode", () => {
window.matchMedia = (query: string): MediaQueryList =>
createMediaQueryList(false, query);
switchToAutoMode();
expect(document.body.classList.contains("dark")).toBeFalse();
window.matchMedia = (query: string): MediaQueryList =>
createMediaQueryList(true, query);
switchToAutoMode();
expect(document.body.classList.contains("dark")).toBeTrue();
window.matchMedia = (query: string): MediaQueryList =>
createMediaQueryList(false, query);
switchToAutoMode();
expect(document.body.classList.contains("dark")).toBeFalse();
});
it("should update theme when media query change event is triggered", () => {
const dummyMediaQuery = createMediaQueryList(false);
window.matchMedia = (query: string): MediaQueryList => dummyMediaQuery;
switchToAutoMode();
expect(document.body.classList.contains("dark")).toBeFalse();
dummyMediaQuery.setMatches(true);
if (dummyMediaQuery.onchange) {
dummyMediaQuery.onchange({ matches: true } as MediaQueryListEvent);
}
expect(document.body.classList.contains("dark")).toBeTrue();
dummyMediaQuery.setMatches(false);
if (dummyMediaQuery.onchange) {
dummyMediaQuery.onchange({ matches: false } as MediaQueryListEvent);
}
expect(document.body.classList.contains("dark")).toBeFalse();
});
it("getCurrentThemeMode should return 'dark' when dark mode is set manually", () => {
switchToDarkMode();
expect(getCurrentThemeMode()).toBe("dark");
});
it("getCurrentThemeMode should return 'light' when light mode is set manually", () => {
switchToLightMode();
expect(getCurrentThemeMode()).toBe("light");
});
it("getCurrentThemeMode should return 'auto' when auto mode is enabled", () => {
window.matchMedia = (query: string): MediaQueryList =>
createMediaQueryList(true, query);
switchToAutoMode();
expect(getCurrentThemeMode()).toBe("auto");
});
});
+117
View File
@@ -0,0 +1,117 @@
import React, {
createContext,
useContext,
useEffect,
useState,
useMemo,
ReactNode,
} from "react";
export type ThemeMode = "light" | "dark" | "auto";
type ThemeChangeListener = (mode: ThemeMode) => void;
const listeners = new Set<ThemeChangeListener>();
function notifyThemeChange() {
const mode = getCurrentThemeMode();
listeners.forEach((listener) => listener(mode));
}
function subscribeToThemeChange(listener: ThemeChangeListener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function updateTheme(darkPreferred: boolean): void {
if (darkPreferred) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}
let currentMediaQuery: MediaQueryList | null = null;
export function switchToDarkMode(): void {
if (currentMediaQuery) {
currentMediaQuery.onchange = null;
currentMediaQuery = null;
}
document.body.classList.add("dark");
notifyThemeChange();
}
export function switchToLightMode(): void {
if (currentMediaQuery) {
currentMediaQuery.onchange = null;
currentMediaQuery = null;
}
document.body.classList.remove("dark");
notifyThemeChange();
}
export function switchToAutoMode(): void {
if (currentMediaQuery) {
currentMediaQuery.onchange = null;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.onchange = (e: MediaQueryListEvent) => {
updateTheme(e.matches);
};
currentMediaQuery = mediaQuery;
updateTheme(mediaQuery.matches);
notifyThemeChange();
}
export function getCurrentThemeMode(): ThemeMode {
if (currentMediaQuery) {
return "auto";
}
return document.body.classList.contains("dark") ? "dark" : "light";
}
interface ThemeModeContextValue {
mode: ThemeMode;
switchToDarkMode: () => void;
switchToLightMode: () => void;
switchToAutoMode: () => void;
}
const ThemeModeContext = createContext<ThemeModeContextValue | null>(null);
export function ThemeModeProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<ThemeMode>(() => getCurrentThemeMode());
useEffect(() => {
const unsubscribe = subscribeToThemeChange((newMode) => {
setMode(newMode);
});
return unsubscribe;
}, []);
const value = useMemo(
() => ({
mode,
switchToDarkMode,
switchToLightMode,
switchToAutoMode,
}),
[mode],
);
return (
<ThemeModeContext.Provider value={value}>
{children}
</ThemeModeContext.Provider>
);
}
export function useThemeMode(): ThemeModeContextValue {
const context = useContext(ThemeModeContext);
if (!context) {
throw new Error("useThemeMode must be used within a ThemeModeProvider");
}
return context;
}
+16
View File
@@ -0,0 +1,16 @@
import { useRef, useLayoutEffect, useCallback } from "react";
export function useCallbackRef<T extends (...args: any[]) => any>(
callback: T,
): T {
const callbackRef = useRef(callback);
useLayoutEffect(() => {
callbackRef.current = callback;
});
return useCallback(
((...args) => callbackRef.current?.(...args)) as T,
[],
) as T;
}
+20
View File
@@ -0,0 +1,20 @@
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[value, delay],
);
return debouncedValue;
}
+5
View File
@@ -0,0 +1,5 @@
import { useMediaQuery } from "./useMediaQuery";
export const useIsMobile = (): boolean => {
return useMediaQuery("(max-width: 767px)");
};
+46
View File
@@ -0,0 +1,46 @@
import { renderHook } from "@testing-library/react";
import { useMediaQuery } from "./useMediaQuery";
describe("useMediaQuery", () => {
let matchMedia: jasmine.Spy;
let addListener: jasmine.Spy;
let removeListener: jasmine.Spy;
beforeEach(() => {
addListener = jasmine.createSpy("addEventListener");
removeListener = jasmine.createSpy("removeEventListener");
matchMedia = jasmine.createSpy("matchMedia").and.returnValue({
matches: false,
addEventListener: addListener,
removeEventListener: removeListener,
});
(window as any).matchMedia = matchMedia;
});
it("should return false by default", () => {
const { result } = renderHook(() => useMediaQuery("(min-width: 768px)"));
expect(result.current).toBe(false);
});
it("should call matchMedia with the provided query", () => {
const query = "(min-width: 768px)";
renderHook(() => useMediaQuery(query));
expect(matchMedia).toHaveBeenCalledWith(query);
});
it("should add event listener on mount", () => {
renderHook(() => useMediaQuery("(min-width: 768px)"));
expect(addListener).toHaveBeenCalledWith("change", jasmine.any(Function));
});
it("should remove event listener on unmount", () => {
const { unmount } = renderHook(() => useMediaQuery("(min-width: 768px)"));
unmount();
expect(removeListener).toHaveBeenCalledWith(
"change",
jasmine.any(Function),
);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { useState, useLayoutEffect } from "react";
type UseMediaQueryOptions = {
defaultValue?: boolean;
initializeWithValue?: boolean;
};
const IS_SERVER = typeof window === "undefined";
export function useMediaQuery(
query: string,
{
defaultValue = false,
initializeWithValue = true,
}: UseMediaQueryOptions = {},
): boolean {
const getMatches = (query: string): boolean => {
if (IS_SERVER) {
return defaultValue;
}
return window.matchMedia(query).matches;
};
const [matches, setMatches] = useState<boolean>(() => {
if (initializeWithValue) {
return getMatches(query);
}
return defaultValue;
});
function handleChange() {
setMatches(getMatches(query));
}
useLayoutEffect(() => {
const matchMedia = window.matchMedia(query);
handleChange();
if (matchMedia.addListener) {
matchMedia.addListener(handleChange);
} else {
matchMedia.addEventListener("change", handleChange);
}
return () => {
if (matchMedia.removeListener) {
matchMedia.removeListener(handleChange);
} else {
matchMedia.removeEventListener("change", handleChange);
}
};
}, [query]);
return matches;
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useRef } from "react";
import { useCallbackRef } from "./useCallbackRef";
export interface ScrollRevealOptions {
threshold?: number | number[];
rootMargin?: string;
}
export function useScrollReveal(options: ScrollRevealOptions = {}) {
const observerRef = useRef<IntersectionObserver | null>(null);
const observedElementsRef = useRef<Set<HTMLElement>>(new Set());
const mutationObserversRef = useRef<Map<Element, MutationObserver>>(
new Map(),
);
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
}, [options]);
const getObserver = () => {
if (!observerRef.current) {
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.setAttribute("data-revealed", "true");
const revealChildren = (el: Element) => {
Array.from(el.children).forEach((child) => {
if (child.getAttribute("data-revealed") !== "true") {
child.setAttribute("data-revealed", "true");
}
revealChildren(child);
});
};
revealChildren(entry.target);
if (!mutationObserversRef.current.has(entry.target)) {
const mo = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (node instanceof Element) {
if (node.getAttribute("data-revealed") !== "true") {
node.setAttribute("data-revealed", "true");
}
revealChildren(node);
}
});
}
});
});
mo.observe(entry.target, { childList: true, subtree: true });
mutationObserversRef.current.set(entry.target, mo);
}
observerRef.current?.unobserve(entry.target);
if (entry.target instanceof HTMLElement) {
observedElementsRef.current.delete(entry.target);
}
}
});
},
{
threshold: optionsRef.current.threshold ?? 0.15,
rootMargin: optionsRef.current.rootMargin ?? "0px 0px -40px 0px",
},
);
}
return observerRef.current;
};
useEffect(() => {
return () => {
observerRef.current?.disconnect();
observedElementsRef.current.clear();
mutationObserversRef.current.forEach((mo) => mo.disconnect());
mutationObserversRef.current.clear();
};
}, []);
const observe = useCallbackRef((element: HTMLElement | null) => {
if (!element) return;
if (typeof IntersectionObserver === "undefined") {
element.setAttribute("data-revealed", "true");
return;
}
if (typeof window !== "undefined" && window.matchMedia) {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
if (mediaQuery.matches) {
element.setAttribute("data-revealed", "true");
return;
}
}
if (
!observedElementsRef.current.has(element) &&
element.getAttribute("data-revealed") !== "true"
) {
getObserver().observe(element);
observedElementsRef.current.add(element);
}
});
return observe;
}
+103
View File
@@ -0,0 +1,103 @@
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 };
}
+393
View File
@@ -0,0 +1,393 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getSpotifySession } from "../endpoints/spotify-session_GET.schema";
import { getDesktopBridge } from "./desktopBridge";
type WebPlaybackTrack = {
uri: string;
name: string;
artists: Array<{ name: string }>;
album: { name: string; images: Array<{ url: string }> };
};
type WebPlaybackState = {
paused: boolean;
position: number;
duration: number;
track_window: { current_track: WebPlaybackTrack };
};
type SpotifyPlayerInstance = {
connect(): Promise<boolean>;
disconnect(): void;
addListener(event: string, callback: (value: any) => void): boolean;
removeListener(event?: string): void;
togglePlay(): Promise<void>;
nextTrack(): Promise<void>;
previousTrack(): Promise<void>;
seek(positionMs: number): Promise<void>;
setVolume(volume: number): Promise<void>;
getCurrentState(): Promise<WebPlaybackState | null>;
};
declare global {
interface Window {
Spotify?: {
Player: new (options: {
name: string;
getOAuthToken: (callback: (token: string) => void) => void;
volume?: number;
}) => SpotifyPlayerInstance;
};
onSpotifyWebPlaybackSDKReady?: () => void;
}
}
export type SpotifyPlaybackView = {
title: string;
artist: string;
album: string;
imageUrl?: string;
uri?: string;
externalUrl?: string;
};
let widevineCheck: Promise<boolean> | undefined;
function hasWidevine() {
widevineCheck ??= navigator
.requestMediaKeySystemAccess("com.widevine.alpha", [
{ initDataTypes: ["cenc"], audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }] },
])
.then(() => true)
.catch(() => false);
return widevineCheck;
}
export function useSpotifyPlayer(accessToken?: string) {
const [mode, setMode] = useState<"checking" | "in-app" | "remote">(getDesktopBridge() ? "checking" : "in-app");
useEffect(() => {
if (mode !== "checking") return;
hasWidevine().then((available) => setMode(available ? "in-app" : "remote"));
}, [mode]);
const remote = useSpotifyConnectRemote(mode === "remote" ? accessToken : undefined);
const inApp = useWebPlaybackPlayer(mode === "in-app" ? accessToken : undefined);
return mode === "remote" ? remote : inApp;
}
function useWebPlaybackPlayer(accessToken?: string) {
const playerRef = useRef<SpotifyPlayerInstance | null>(null);
const [deviceId, setDeviceId] = useState<string>();
const [ready, setReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [positionMs, setPositionMs] = useState(0);
const [durationMs, setDurationMs] = useState(0);
const [volume, setVolumeState] = useState(0.7);
const [track, setTrack] = useState<SpotifyPlaybackView>();
const [error, setError] = useState<string>();
const applyState = useCallback((state: WebPlaybackState | null) => {
if (!state) return;
setIsPlaying(!state.paused);
setPositionMs(state.position || 0);
setDurationMs(state.duration || 0);
const current = state.track_window?.current_track;
if (current) {
setTrack({
title: current.name,
artist: current.artists?.map((artist) => artist.name).join(", ") || "Unknown artist",
album: current.album?.name || "",
imageUrl: current.album?.images?.[0]?.url,
uri: current.uri,
externalUrl: current.uri?.startsWith("spotify:track:")
? `https://open.spotify.com/track/${current.uri.split(":").pop()}`
: undefined,
});
}
}, []);
useEffect(() => {
if (!accessToken) {
playerRef.current?.disconnect();
playerRef.current = null;
setReady(false);
setDeviceId(undefined);
return;
}
let cancelled = false;
const initialize = () => {
if (cancelled || !window.Spotify || playerRef.current) return;
const player = new window.Spotify.Player({
name: "Aurora SpotiRip",
getOAuthToken: (callback) => callback(accessToken),
volume,
});
playerRef.current = player;
player.addListener("ready", ({ device_id }: { device_id: string }) => {
console.warn(`Spotify player ready: in-app device ${device_id}`);
setDeviceId(device_id);
setReady(true);
setError(undefined);
});
player.addListener("not_ready", () => setReady(false));
player.addListener("player_state_changed", applyState);
for (const event of ["initialization_error", "authentication_error", "account_error", "playback_error"]) {
player.addListener(event, ({ message }: { message: string }) => {
console.warn(`Spotify player ${event}: ${message}`);
setError(message);
});
}
player.connect().catch((reason) => setError(String(reason)));
};
if (window.Spotify) {
initialize();
} else {
const existing = document.getElementById("spotify-player-sdk");
window.onSpotifyWebPlaybackSDKReady = initialize;
if (!existing) {
const script = document.createElement("script");
script.id = "spotify-player-sdk";
script.src = "https://sdk.scdn.co/spotify-player.js";
script.async = true;
document.body.appendChild(script);
}
}
return () => {
cancelled = true;
playerRef.current?.disconnect();
playerRef.current = null;
};
}, [accessToken, applyState]);
useEffect(() => {
if (!ready || !isPlaying) return;
const timer = window.setInterval(async () => {
const state = await playerRef.current?.getCurrentState();
if (state) applyState(state);
}, 1000);
return () => window.clearInterval(timer);
}, [ready, isPlaying, applyState]);
const playUri = useCallback(async (uri: string) => {
if (!accessToken || !deviceId) throw new Error("Spotify player is not ready yet.");
for (let attempt = 0; ; attempt++) {
const response = await fetch(`https://api.spotify.com/v1/me/player/play?device_id=${encodeURIComponent(deviceId)}`, {
method: "PUT",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ uris: [uri] }),
});
if (response.ok || response.status === 204) return;
const text = await response.text();
if (response.status === 404 && attempt < 4) {
await new Promise((resolve) => window.setTimeout(resolve, 1000));
continue;
}
console.warn(`Spotify player PUT /play (in-app) -> ${response.status}: ${text}`);
let detail = text.slice(0, 180);
try {
const parsed = JSON.parse(text) as { error?: { message?: string; reason?: string } };
detail = [parsed.error?.message, parsed.error?.reason].filter(Boolean).join(" · ") || detail;
} catch {
}
throw new Error(`Spotify refused playback (${response.status}): ${detail || "no reason given"}`);
}
}, [accessToken, deviceId]);
const setVolume = useCallback(async (next: number) => {
const safe = Math.max(0, Math.min(1, next));
setVolumeState(safe);
await playerRef.current?.setVolume(safe);
}, []);
return {
ready,
deviceId,
isPlaying,
positionMs,
durationMs,
volume,
track,
error,
playUri,
togglePlay: () => playerRef.current?.togglePlay(),
nextTrack: () => playerRef.current?.nextTrack(),
previousTrack: () => playerRef.current?.previousTrack(),
seek: (position: number) => playerRef.current?.seek(position),
setVolume,
};
}
type ConnectPlayerState = {
is_playing: boolean;
progress_ms: number | null;
device?: { id: string | null; name: string; volume_percent: number | null };
item?: {
uri: string;
name: string;
duration_ms: number;
artists?: Array<{ name: string }>;
album?: { name: string; images?: Array<{ url: string }> };
external_urls?: { spotify?: string };
} | null;
};
type ConnectDevice = { id: string | null; name: string; is_active: boolean; is_restricted: boolean };
const REMOTE_POLL_MS = 2000;
function useSpotifyConnectRemote(accessToken?: string) {
const tokenRef = useRef(accessToken);
const [deviceId, setDeviceId] = useState<string>();
const [deviceName, setDeviceName] = useState<string>();
const [ready, setReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [positionMs, setPositionMs] = useState(0);
const [durationMs, setDurationMs] = useState(0);
const [volume, setVolumeState] = useState(0.7);
const [track, setTrack] = useState<SpotifyPlaybackView>();
const [error, setError] = useState<string>();
useEffect(() => {
tokenRef.current = accessToken;
}, [accessToken]);
const api = useCallback(async (path: string, init: RequestInit = {}, retried = false): Promise<Response> => {
const response = await fetch("https://api.spotify.com/v1/me/player" + path, {
...init,
headers: { Authorization: `Bearer ${tokenRef.current}`, "Content-Type": "application/json" },
});
if (response.status === 401 && !retried) {
const session = await getSpotifySession();
tokenRef.current = session.accessToken;
return api(path, init, true);
}
return response;
}, []);
const refresh = useCallback(async () => {
if (!tokenRef.current) return;
try {
const response = await api("");
const state = response.status === 200 ? ((await response.json()) as ConnectPlayerState) : undefined;
if (state?.device?.id) {
setDeviceId(state.device.id);
setDeviceName(state.device.name);
setReady(true);
setError(undefined);
setIsPlaying(state.is_playing);
setPositionMs(state.progress_ms || 0);
setDurationMs(state.item?.duration_ms || 0);
if (typeof state.device.volume_percent === "number") setVolumeState(state.device.volume_percent / 100);
const item = state.item;
setTrack(
item
? {
title: item.name,
artist: item.artists?.map((artist) => artist.name).join(", ") || "Unknown artist",
album: `${item.album?.name || ""} · on ${state.device.name}`,
imageUrl: item.album?.images?.[0]?.url,
uri: item.uri,
externalUrl: item.external_urls?.spotify,
}
: undefined,
);
return;
}
const devicesResponse = await api("/devices");
const devices = devicesResponse.ok
? ((await devicesResponse.json()) as { devices: ConnectDevice[] }).devices.filter(
(device) => device.id && !device.is_restricted,
)
: [];
const device = devices.find((entry) => entry.is_active) || devices[0];
setIsPlaying(false);
if (device?.id) {
setDeviceId(device.id);
setDeviceName(device.name);
setReady(true);
setError(undefined);
} else {
setDeviceId(undefined);
setDeviceName(undefined);
setReady(false);
setError("Open Spotify on this computer, your phone or a speaker; SpotiRip will control it from here.");
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to reach Spotify.");
}
}, [api]);
useEffect(() => {
if (!accessToken) {
setReady(false);
return;
}
void refresh();
const timer = window.setInterval(() => void refresh(), REMOTE_POLL_MS);
return () => window.clearInterval(timer);
}, [accessToken, refresh]);
const command = useCallback(
async (path: string, method: "PUT" | "POST", body?: unknown) => {
const response = await api(path, { method, body: body === undefined ? undefined : JSON.stringify(body) });
if (!response.ok && response.status !== 204) {
const text = await response.text();
let detail = text.slice(0, 180);
try {
const parsed = JSON.parse(text) as { error?: { message?: string; reason?: string } };
detail = [parsed.error?.message, parsed.error?.reason].filter(Boolean).join(" · ") || detail;
} catch {
}
const message = `Spotify refused the command (${response.status}): ${detail || "no reason given"}`;
console.warn(`Spotify player ${method} ${path} -> ${response.status}: ${text}`);
setError(message);
throw new Error(message);
}
window.setTimeout(() => void refresh(), 400);
},
[api, refresh],
);
const withDevice = (path: string) =>
deviceId ? `${path}${path.includes("?") ? "&" : "?"}device_id=${encodeURIComponent(deviceId)}` : path;
const playUri = useCallback(
async (uri: string) => {
if (!deviceId) {
throw new Error("Open Spotify on this computer, your phone or a speaker first.");
}
await command(withDevice("/play"), "PUT", { uris: [uri] });
},
[command, deviceId],
);
const setVolume = useCallback(
async (next: number) => {
const safe = Math.max(0, Math.min(1, next));
setVolumeState(safe);
await command(withDevice(`/volume?volume_percent=${Math.round(safe * 100)}`), "PUT");
},
[command, deviceId],
);
return {
ready,
deviceId,
deviceName,
isPlaying,
positionMs,
durationMs,
volume,
track,
error,
playUri,
togglePlay: () => command(withDevice(isPlaying ? "/pause" : "/play"), "PUT"),
nextTrack: () => command(withDevice("/next"), "POST"),
previousTrack: () => command(withDevice("/previous"), "POST"),
seek: (position: number) => command(withDevice(`/seek?position_ms=${Math.round(position)}`), "PUT"),
setVolume,
};
}