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,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());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const PORT = 43117;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user