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
+103
View File
@@ -0,0 +1,103 @@
import { createServer } from "node:http";
import { existsSync, readFileSync, statSync } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { Hono } from "hono";
import { getRequestListener } from "@hono/node-server";
import * as identify from "../endpoints/identify_POST";
import * as lyrics from "../endpoints/lyrics_POST";
import * as metadataEnrich from "../endpoints/metadata-enrich_POST";
import * as spotifyAlbum from "../endpoints/spotify-album_GET";
import * as spotifyArtistAlbums from "../endpoints/spotify-artist-albums_GET";
import * as spotifyAuth from "../endpoints/spotify-auth_GET";
import * as spotifyDownload from "../endpoints/spotify-download_POST";
import * as spotifyDownloadFile from "../endpoints/spotify-download-file_GET";
import * as spotifyDownloadStatus from "../endpoints/spotify-download-status_GET";
import * as spotifyExchange from "../endpoints/spotify-exchange_POST";
import * as spotifyLibrary from "../endpoints/spotify-library_GET";
import * as spotifyLogout from "../endpoints/spotify-logout_POST";
import * as spotifyPlaylist from "../endpoints/spotify-playlist_POST";
import * as spotifyPlaylists from "../endpoints/spotify-playlists_GET";
import * as spotifySession from "../endpoints/spotify-session_GET";
const routes: Array<["get" | "post", string, { handle: (request: Request) => Promise<Response> }]> = [
["post", "identify", identify],
["get", "spotify-auth", spotifyAuth],
["post", "spotify-exchange", spotifyExchange],
["get", "spotify-session", spotifySession],
["post", "spotify-logout", spotifyLogout],
["post", "spotify-playlist", spotifyPlaylist],
["get", "spotify-playlists", spotifyPlaylists],
["post", "metadata-enrich", metadataEnrich],
["post", "lyrics", lyrics],
["get", "spotify-library", spotifyLibrary],
["get", "spotify-artist-albums", spotifyArtistAlbums],
["get", "spotify-album", spotifyAlbum],
["post", "spotify-download", spotifyDownload],
["get", "spotify-download-status", spotifyDownloadStatus],
["get", "spotify-download-file", spotifyDownloadFile],
];
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff2": "font/woff2",
".json": "application/json",
".wasm": "application/wasm",
};
function serveFile(path: string) {
return new Response(readFileSync(path), {
headers: {
"Content-Type": CONTENT_TYPES[extname(path)] || "application/octet-stream",
"Cache-Control": extname(path) === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
},
});
}
type ServerHooks = {
onSpotifyLogin?: (setCookies: string[]) => void;
};
export function startApiServer(port: number, distDir: string, hooks: ServerHooks = {}) {
const app = new Hono();
app.use("/_api/*", async (c, next) => {
await next();
console.log(`${new Date().toISOString()} ${c.req.method} ${c.req.path} -> ${c.res.status}`);
});
app.use("/spotify/*", async (c, next) => {
await next();
console.log(`${new Date().toISOString()} ${c.req.method} ${c.req.path}${new URL(c.req.url).search.replace(/code=[^&]+/, "code=…")} -> ${c.res.status}`);
});
for (const [method, name, endpoint] of routes) {
app[method](`/_api/${name}`, async (c) => {
try {
const response = await endpoint.handle(c.req.raw);
if (name === "spotify-exchange" && response.ok) hooks.onSpotifyLogin?.(response.headers.getSetCookie());
return response;
} catch (error) {
console.error(error);
return c.text("Endpoint error: " + (error instanceof Error ? error.message : String(error)), 500);
}
});
}
app.get("*", (c) => {
if (c.req.path.startsWith("/_api/")) return c.notFound();
const candidate = normalize(join(distDir, decodeURIComponent(c.req.path)));
if (candidate.startsWith(distDir + sep) && existsSync(candidate) && statSync(candidate).isFile()) {
return serveFile(candidate);
}
return serveFile(join(distDir, "index.html"));
});
return new Promise<void>((resolve, reject) => {
const server = createServer(getRequestListener(app.fetch));
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
}
+1
View File
@@ -0,0 +1 @@
export const PORT = 43117;
+33
View File
@@ -0,0 +1,33 @@
import { app } from "electron";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { PORT } from "./constants";
app.setName("Aurora SpotiRip");
export const ENV_FILE = join(app.getPath("userData"), "env.json");
for (const file of [ENV_FILE, join(app.getAppPath(), "env.json")]) {
if (!existsSync(file)) continue;
const values = JSON.parse(readFileSync(file, "utf8")) as Record<string, string>;
for (const [key, value] of Object.entries(values)) {
if (process.env[key] === undefined) process.env[key] = value;
}
}
process.env.SPOTIRIP_DESKTOP = "1";
process.env.SPOTIRIP_BASE_URL = `http://127.0.0.1:${PORT}`;
process.env.SPOTIRIP_DOWNLOAD_USERS = "*";
const exe = process.platform === "win32" ? ".exe" : "";
if (app.isPackaged) {
process.env.SPOTIRIP_DOWNLOADER_BIN = join(process.resourcesPath, "downloader", "spotify_downloader" + exe);
process.env.SPOTIRIP_FFMPEG = join(process.resourcesPath, "ffmpeg", "ffmpeg" + exe);
} else {
process.env.SPOTIRIP_PYTHON ??=
process.platform === "win32"
? join(app.getAppPath(), "venv", "Scripts", "python.exe")
: join(app.getAppPath(), "venv", "bin", "python");
process.env.SPOTIRIP_DOWNLOADER ??= join(app.getAppPath(), "spotify_downloader.py");
}
+142
View File
@@ -0,0 +1,142 @@
import "./env";
import { app, BrowserWindow, components, dialog, ipcMain, session, shell } from "electron";
import { join } from "node:path";
import { startApiServer } from "./apiServer";
import { PORT } from "./constants";
import { applySettings, loadSettings, saveSettings } from "./settings";
const APP_URL = `http://127.0.0.1:${PORT}`;
const LOGIN_HOSTS = ["spotify.com", "google.com", "facebook.com", "apple.com"];
let mainWindow: BrowserWindow | undefined;
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(start);
}
async function start() {
app.userAgentFallback = app.userAgentFallback.replace(/ (Electron|aurora-spotirip|Aurora SpotiRip)\/\S+/g, "");
applySettings();
registerIpc();
try {
await startApiServer(PORT, join(app.getAppPath(), "dist"), { onSpotifyLogin: adoptSpotifyLogin });
} catch (error) {
const inUse = (error as NodeJS.ErrnoException).code === "EADDRINUSE";
dialog.showErrorBox(
"Aurora SpotiRip could not start",
inUse
? `Port ${PORT} is already in use by another program. Close it and start Aurora SpotiRip again.`
: String(error),
);
app.quit();
return;
}
try {
await components.whenReady();
} catch (error) {
console.error("Widevine is unavailable; in-app playback will use Spotify Connect instead.", error);
}
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1360,
height: 880,
minWidth: 960,
minHeight: 600,
backgroundColor: "#0f1318",
title: "Aurora SpotiRip",
icon: join(app.getAppPath(), "build", "icon.png"),
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const { origin, hostname, pathname } = new URL(url);
if (origin === APP_URL && pathname === "/_api/spotify-auth") {
event.preventDefault();
shell.openExternal(url);
return;
}
if (origin === APP_URL || LOGIN_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`))) return;
event.preventDefault();
shell.openExternal(url);
});
mainWindow.webContents.on("console-message", (event) => {
if (event.level === "warning" || event.level === "error") {
console.log(`[page ${event.level}] ${event.message}`);
}
});
mainWindow.loadURL(APP_URL);
mainWindow.on("closed", () => {
mainWindow = undefined;
});
}
async function adoptSpotifyLogin(setCookies: string[]) {
for (const header of setCookies) {
const [pair, ...attributes] = header.split(";").map((part) => part.trim());
const index = pair.indexOf("=");
const name = pair.slice(0, index);
const value = decodeURIComponent(pair.slice(index + 1));
const maxAge = Number(attributes.find((attr) => /^max-age=/i.test(attr))?.split("=")[1]);
if (!name || name === "spotirip_oauth_state") continue;
await session.defaultSession.cookies.set({
url: APP_URL,
name,
value,
path: "/",
secure: true,
httpOnly: attributes.some((attr) => /^httponly$/i.test(attr)),
sameSite: "lax",
...(Number.isFinite(maxAge) ? { expirationDate: Date.now() / 1000 + maxAge } : {}),
});
}
if (!mainWindow) createWindow();
else {
mainWindow.loadURL(APP_URL);
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
}
function registerIpc() {
ipcMain.handle("settings:get-download-folder", () => loadSettings().downloadFolder);
ipcMain.handle("settings:choose-download-folder", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Choose download folder",
defaultPath: loadSettings().downloadFolder,
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return undefined;
return saveSettings({ downloadFolder: result.filePaths[0] }).downloadFolder;
});
ipcMain.handle("shell:show-in-folder", (_event, path: string) => shell.showItemInFolder(path));
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
+7
View File
@@ -0,0 +1,7 @@
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("spotiripDesktop", {
getDownloadFolder: () => ipcRenderer.invoke("settings:get-download-folder"),
chooseDownloadFolder: () => ipcRenderer.invoke("settings:choose-download-folder"),
showInFolder: (path: string) => ipcRenderer.invoke("shell:show-in-folder", path),
});
+32
View File
@@ -0,0 +1,32 @@
import { app } from "electron";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
type DesktopSettings = { downloadFolder: string };
const SETTINGS_FILE = join(app.getPath("userData"), "settings.json");
function defaults(): DesktopSettings {
return { downloadFolder: join(app.getPath("music"), "Aurora SpotiRip") };
}
export function loadSettings(): DesktopSettings {
try {
if (existsSync(SETTINGS_FILE)) {
return { ...defaults(), ...JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) };
}
} catch {
}
return defaults();
}
export function saveSettings(changes: Partial<DesktopSettings>) {
const next = { ...loadSettings(), ...changes };
writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2));
applySettings(next);
return next;
}
export function applySettings(settings = loadSettings()) {
process.env.SPOTIRIP_DOWNLOAD_FOLDER = settings.downloadFolder;
}