- Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts
104 lines
4.3 KiB
TypeScript
104 lines
4.3 KiB
TypeScript
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());
|
|
});
|
|
}
|