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 });