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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
|
||||
export const useIsMobile = (): boolean => {
|
||||
return useMediaQuery("(max-width: 767px)");
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user