mymusics

retro MySpace-style music player
Log | Files | Refs | README

index.ts (11526B)


      1 import fs from "node:fs";
      2 import path from "node:path";
      3 
      4 import cors from "@fastify/cors";
      5 import fastifyStatic from "@fastify/static";
      6 import dotenv from "dotenv";
      7 import Fastify from "fastify";
      8 
      9 import { buildOEmbedResponse } from "./oembed.js";
     10 import {
     11   IA_DRAGON_HOARD_ID,
     12   loadTracksFromTsv,
     13   type TrackMeta,
     14 } from "./metadata.js";
     15 import {
     16   bundledMetadataTsv,
     17   getProjectRoot,
     18   resolveEffectiveMetadataTsv,
     19   resolveTracksDb,
     20 } from "./paths.js";
     21 import { rateLimit } from "./rateLimit.js";
     22 import { TrackStore } from "./trackStore.js";
     23 import { resolveApiPort } from "../config/ports.js";
     24 
     25 const PROJECT_ROOT = getProjectRoot();
     26 
     27 dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });
     28 
     29 const PORT = resolveApiPort(process.env);
     30 const IA_ITEM_ID = process.env.IA_ITEM_ID?.trim() || IA_DRAGON_HOARD_ID;
     31 const PUBLIC_SITE_URL =
     32   process.env.PUBLIC_SITE_URL?.trim() || "https://mymusics.murad.gg";
     33 
     34 let METADATA_TSV = "";
     35 let METADATA_ENV_REQUESTED: string | null = null;
     36 let METADATA_USED_FALLBACK = false;
     37 let TRACKS_DB_PATH = "";
     38 
     39 let store: TrackStore | null = null;
     40 let tsvFallbackPool: TrackMeta[] = [];
     41 let useTsvFallback = false;
     42 let metadataLoadHint: string | null = null;
     43 
     44 function applyPathsFromEnv() {
     45   dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });
     46   const m = resolveEffectiveMetadataTsv(process.env, PROJECT_ROOT);
     47   METADATA_TSV = m.path;
     48   METADATA_ENV_REQUESTED = m.envRequested;
     49   METADATA_USED_FALLBACK = m.usedFallback;
     50   TRACKS_DB_PATH = resolveTracksDb(process.env, PROJECT_ROOT);
     51 }
     52 
     53 function hintForMetadataNotFound(message: string): string {
     54   if (!message.includes("not found")) return message;
     55   const bundled = bundledMetadataTsv(PROJECT_ROOT);
     56   if (fs.existsSync(bundled) && METADATA_TSV !== bundled) {
     57     return `${message} Your METADATA_TSV points elsewhere, but the repo file exists at ${bundled}. Set METADATA_TSV to that path, or remove METADATA_TSV from .env/PM2 to use the default.`;
     58   }
     59   return `${message} Run npm run index-metadata after placing metadata.tsv.`;
     60 }
     61 
     62 function diagnoseEmptyMetadata(tsvPath: string) {
     63   if (!fs.existsSync(tsvPath)) {
     64     return `File does not exist. Copy metadata.tsv from the Dragon Hoard dataset and run npm run index-metadata.`;
     65   }
     66   const stat = fs.statSync(tsvPath);
     67   if (stat.size === 0) return "File is empty (0 bytes).";
     68   return "Rows parsed but no valid tracks in database. Run npm run index-metadata.";
     69 }
     70 
     71 function trackCount(): number {
     72   if (store && !useTsvFallback) return store.count();
     73   return tsvFallbackPool.length;
     74 }
     75 
     76 function pickRandom(excludeId?: string): TrackMeta | null {
     77   if (store && !useTsvFallback) return store.random(excludeId);
     78   if (!tsvFallbackPool.length) return null;
     79   const ex = excludeId?.trim();
     80   if (ex && tsvFallbackPool.length > 1) {
     81     const filtered = tsvFallbackPool.filter((t) => t.id !== ex);
     82     if (filtered.length > 0) {
     83       return filtered[Math.floor(Math.random() * filtered.length)]!;
     84     }
     85   }
     86   return tsvFallbackPool[Math.floor(Math.random() * tsvFallbackPool.length)]!;
     87 }
     88 
     89 function getById(id: string): TrackMeta | null {
     90   if (store && !useTsvFallback) return store.getById(id);
     91   return tsvFallbackPool.find((t) => t.id === id.trim()) ?? null;
     92 }
     93 
     94 function searchTracks(q: string, limit: number) {
     95   if (store && !useTsvFallback) return store.search(q, limit);
     96   const trimmed = q.trim().toLowerCase();
     97   if (trimmed.length < 2) return [];
     98   return tsvFallbackPool
     99     .filter(
    100       (t) =>
    101         t.title.toLowerCase().includes(trimmed) ||
    102         t.artist.toLowerCase().includes(trimmed),
    103     )
    104     .slice(0, limit)
    105     .map((t) => ({ id: t.id, title: t.title, artist: t.artist }));
    106 }
    107 
    108 function toTrackPayload(track: TrackMeta) {
    109   return {
    110     track: {
    111       id: track.id,
    112       title: track.title,
    113       artist: track.artist,
    114       fileKey: track.fileKey,
    115     },
    116     streamUrl: track.archiveUrl,
    117   };
    118 }
    119 
    120 function rebuildStore() {
    121   metadataLoadHint = null;
    122   applyPathsFromEnv();
    123 
    124   if (fs.existsSync(TRACKS_DB_PATH)) {
    125     store?.close();
    126     store = new TrackStore(TRACKS_DB_PATH);
    127     store.open();
    128     useTsvFallback = false;
    129     const count = store.count();
    130     console.info(`MyMusics: ${count} tracks from SQLite ${TRACKS_DB_PATH}`);
    131     if (count === 0) {
    132       metadataLoadHint = diagnoseEmptyMetadata(METADATA_TSV);
    133       console.warn(`MyMusics: 0 tracks — ${metadataLoadHint}`);
    134     }
    135     return;
    136   }
    137 
    138   console.warn(
    139     `MyMusics: ${TRACKS_DB_PATH} missing — falling back to TSV (slow). Run: npm run index-metadata`,
    140   );
    141   store?.close();
    142   store = null;
    143   useTsvFallback = true;
    144   tsvFallbackPool = loadTracksFromTsv(METADATA_TSV, IA_ITEM_ID);
    145   console.info(`MyMusics: loaded ${tsvFallbackPool.length} tracks from TSV fallback`);
    146   if (tsvFallbackPool.length === 0) {
    147     metadataLoadHint = diagnoseEmptyMetadata(METADATA_TSV);
    148   }
    149 }
    150 
    151 function resolveCorsOrigin(): boolean | string | RegExp | (string | RegExp)[] {
    152   const raw = process.env.CORS_ORIGINS?.trim();
    153   if (!raw) {
    154     if (process.env.NODE_ENV === "production") {
    155       return [PUBLIC_SITE_URL, "https://mymusics.murad.gg"];
    156     }
    157     return true;
    158   }
    159   if (raw === "*") return true;
    160   return raw.split(",").map((s) => s.trim()).filter(Boolean);
    161 }
    162 
    163 const distDir = path.join(PROJECT_ROOT, "dist");
    164 const distIndexPath = path.join(distDir, "index.html");
    165 const distExists = fs.existsSync(distIndexPath);
    166 
    167 const staticDisabled =
    168   process.env.SERVE_STATIC === "false" || process.env.SERVE_STATIC === "0";
    169 const staticExplicit =
    170   process.env.SERVE_STATIC === "true" || process.env.SERVE_STATIC === "1";
    171 const serveStatic = !staticDisabled && (staticExplicit || distExists);
    172 
    173 async function main() {
    174   applyPathsFromEnv();
    175   console.info(`MyMusics: cwd=${process.cwd()}`);
    176   console.info(`MyMusics: metadata ${METADATA_TSV}`);
    177   console.info(`MyMusics: tracks db ${TRACKS_DB_PATH}`);
    178 
    179   try {
    180     rebuildStore();
    181   } catch (e) {
    182     console.error(e);
    183     store = null;
    184     useTsvFallback = true;
    185     tsvFallbackPool = [];
    186     const raw = e instanceof Error ? e.message : "Failed to load tracks.";
    187     metadataLoadHint = hintForMetadataNotFound(raw);
    188   }
    189 
    190   const app = Fastify({ logger: true });
    191   await app.register(cors, { origin: resolveCorsOrigin() });
    192 
    193   app.addHook("onSend", async (_request, reply, payload) => {
    194     const ct = reply.getHeader("content-type");
    195     const ctStr = Array.isArray(ct) ? ct[0] : ct;
    196     if (typeof ctStr === "string" && ctStr.includes("text/html")) {
    197       reply.header("Content-Security-Policy", "frame-ancestors *");
    198       reply.removeHeader("x-frame-options");
    199       reply.removeHeader("X-Frame-Options");
    200     }
    201     return payload;
    202   });
    203 
    204   app.get("/api/health", async () => {
    205     let metadataSizeBytes: number | null = null;
    206     try {
    207       if (fs.existsSync(METADATA_TSV)) metadataSizeBytes = fs.statSync(METADATA_TSV).size;
    208     } catch {
    209       metadataSizeBytes = null;
    210     }
    211     const count = trackCount();
    212     return {
    213       ok: true,
    214       trackCount: count,
    215       tracksReady: count > 0,
    216       tracksDb: TRACKS_DB_PATH,
    217       tracksDbExists: fs.existsSync(TRACKS_DB_PATH),
    218       useTsvFallback,
    219       ftsReady: store ? store.ftsReady() : false,
    220       blockedCount: store && !useTsvFallback ? store.blockedCount() : 0,
    221       metadataTsv: METADATA_TSV,
    222       ...(METADATA_ENV_REQUESTED && METADATA_ENV_REQUESTED !== METADATA_TSV
    223         ? { metadataEnvRequested: METADATA_ENV_REQUESTED, metadataUsedFallback: METADATA_USED_FALLBACK }
    224         : {}),
    225       metadataExists: fs.existsSync(METADATA_TSV),
    226       metadataSizeBytes,
    227       cwd: process.cwd(),
    228       iaItemId: IA_ITEM_ID,
    229       hint:
    230         metadataLoadHint ??
    231         (!fs.existsSync(TRACKS_DB_PATH) && !useTsvFallback
    232           ? "Run npm run index-metadata to build data/tracks.db"
    233           : undefined),
    234     };
    235   });
    236 
    237   app.post("/api/reload", async (_req, reply) => {
    238     try {
    239       rebuildStore();
    240       return reply.send({ ok: true, trackCount: trackCount() });
    241     } catch (e) {
    242       const message = e instanceof Error ? e.message : "Failed to reload";
    243       return reply.code(500).send({ ok: false, error: message });
    244     }
    245   });
    246 
    247   app.get("/api/track/search", async (req, reply) => {
    248     const q = (req.query as { q?: string }).q ?? "";
    249     const limitRaw = (req.query as { limit?: string }).limit;
    250     const limit = limitRaw ? Math.min(50, Math.max(1, Number(limitRaw) || 20)) : 20;
    251     return reply.send({ tracks: searchTracks(q, limit) });
    252   });
    253 
    254   app.get("/api/track/random", async (_req, reply) => {
    255     const track = pickRandom();
    256     if (!track) {
    257       return reply.code(503).send({
    258         error: "No tracks available. Check that metadata loaded correctly.",
    259       });
    260     }
    261     return reply.send(toTrackPayload(track));
    262   });
    263 
    264   app.get("/api/track/up-next", async (req, reply) => {
    265     const raw = (req.query as { exclude?: string }).exclude;
    266     const excludeId = typeof raw === "string" ? raw.trim() : undefined;
    267     const track = pickRandom(excludeId);
    268     if (!track) {
    269       return reply.code(503).send({
    270         error: "No tracks available. Check that metadata loaded correctly.",
    271       });
    272     }
    273     return reply.send(toTrackPayload(track));
    274   });
    275 
    276   app.get("/api/track/:id", async (req, reply) => {
    277     const id = (req.params as { id: string }).id?.trim();
    278     if (!id) return reply.code(400).send({ error: "Missing track id" });
    279     const track = getById(id);
    280     if (!track) return reply.code(404).send({ error: "Track not found" });
    281     return reply.send(toTrackPayload(track));
    282   });
    283 
    284   app.post("/api/events", async (req, reply) => {
    285     const ip = req.ip;
    286     const rl = rateLimit(`events:${ip}`, 60, 60_000);
    287     if (!rl.ok) {
    288       return reply.code(429).send({ error: "Too many events", retryAfterSec: rl.retryAfterSec });
    289     }
    290     const body = req.body as {
    291       type?: string;
    292       trackId?: string;
    293       detail?: string;
    294       ms?: number;
    295     };
    296     const type = body?.type?.trim();
    297     if (type !== "stream_error" && type !== "time_to_play") {
    298       return reply.code(400).send({ error: "Invalid event type" });
    299     }
    300     req.log.info({ event: type, trackId: body.trackId, detail: body.detail, ms: body.ms });
    301     return reply.send({ ok: true });
    302   });
    303 
    304   app.get("/api/oembed", async (req, reply) => {
    305     const url = (req.query as { url?: string }).url ?? "";
    306     const data = buildOEmbedResponse(url, PUBLIC_SITE_URL);
    307     if (!data) return reply.code(404).send({ error: "URL not supported for oEmbed" });
    308     return reply.send(data);
    309   });
    310 
    311   app.get("/.well-known/oembed", async (req, reply) => {
    312     const url = (req.query as { url?: string }).url ?? "";
    313     const data = buildOEmbedResponse(url, PUBLIC_SITE_URL);
    314     if (!data) return reply.code(404).send({ error: "URL not supported for oEmbed" });
    315     return reply.send(data);
    316   });
    317 
    318   if (serveStatic) {
    319     if (distExists) {
    320       await app.register(fastifyStatic, {
    321         root: distDir,
    322         prefix: "/",
    323       });
    324       app.setNotFoundHandler((request, reply) => {
    325         const pathname = request.url.split("?")[0] ?? "";
    326         if (pathname.startsWith("/api")) {
    327           return reply.code(404).send({ error: "Not found" });
    328         }
    329         return reply.sendFile("index.html");
    330       });
    331       console.info(`MyMusics: serving SPA + /api from ${distDir}`);
    332     } else {
    333       console.warn(
    334         "MyMusics: SERVE_STATIC requested but dist/index.html is missing — run `npm run build` on the server.",
    335       );
    336     }
    337   }
    338 
    339   await app.listen({ port: PORT, host: "0.0.0.0" });
    340 }
    341 
    342 main().catch((e) => {
    343   console.error(e);
    344   process.exit(1);
    345 });