shared.ts
| 1 | import path from "node:path"; |
| 2 | import { parseArgs } from "node:util"; |
| 3 | import commandExists from "command-exists"; |
| 4 | import type { Metadata } from "music-server-shared/types"; |
| 5 | |
| 6 | export const coverRegex = |
| 7 | process.env.COVER_REGEX === undefined |
| 8 | ? /(cover|folder)\.(png|jpe?g)$/i |
| 9 | : process.env.COVER_REGEX === "" |
| 10 | ? "" |
| 11 | : new RegExp(process.env.COVER_REGEX, "i"); |
| 12 | export const musicRoot = process.env.MUSIC_ROOT || ""; |
| 13 | if (musicRoot === "") { |
| 14 | console.error("Please define the environment variable MUSIC_ROOT"); |
| 15 | process.exit(1); |
| 16 | } |
| 17 | |
| 18 | export const [username, password] = process.env.AUTH |
| 19 | ? Buffer.from(process.env.AUTH, "base64").toString().split(":", 2) |
| 20 | : [undefined, undefined]; |
| 21 | |
| 22 | export const authTokens = new Map<string, Date>(); |
| 23 | |
| 24 | //clear outdated tokens once a day |
| 25 | setInterval( |
| 26 | () => { |
| 27 | for (const [token, endDate] of authTokens.entries()) { |
| 28 | if (endDate.getTime() < Date.now()) authTokens.delete(token); |
| 29 | } |
| 30 | }, |
| 31 | 1000 * 60 * 60 * 24, |
| 32 | ); |
| 33 | |
| 34 | if (!path.isAbsolute(musicRoot)) { |
| 35 | console.error(`Music root ${musicRoot} is not absolute`); |
| 36 | process.exit(1); |
| 37 | } |
| 38 | |
| 39 | export const excludeExtension = |
| 40 | process.env.EXCLUDE_EXTENSION === undefined |
| 41 | ? ["txt", "log", "nfo", "m3u", "htm", "html"] |
| 42 | : process.env.EXCLUDE_EXTENSION.split(","); |
| 43 | |
| 44 | //number of parallel mediainfo processes used while scanning uncached folders |
| 45 | export const scanConcurrency = (() => { |
| 46 | if (process.env.SCAN_CONCURRENCY === undefined) return 8; |
| 47 | const parsed = Number.parseInt(process.env.SCAN_CONCURRENCY, 10); |
| 48 | if (Number.isNaN(parsed) || parsed <= 0) return 8; |
| 49 | return parsed; |
| 50 | })(); |
| 51 | |
| 52 | if (!commandExists.sync("ffmpeg")) { |
| 53 | console.error("Required command ffmpeg not found in PATH"); |
| 54 | process.exit(1); |
| 55 | } |
| 56 | if (!commandExists.sync("tar")) { |
| 57 | console.error("Required command tar not found in PATH"); |
| 58 | process.exit(1); |
| 59 | } |
| 60 | if (!commandExists.sync("avifenc")) { |
| 61 | console.error("Required command avifenc not found in PATH"); |
| 62 | process.exit(1); |
| 63 | } |
| 64 | if (!commandExists.sync("mediainfo")) { |
| 65 | console.error("Required command mediainfo not found in PATH"); |
| 66 | process.exit(1); |
| 67 | } |
| 68 | |
| 69 | export const { values: args } = parseArgs({ |
| 70 | args: Bun.argv, |
| 71 | options: { |
| 72 | serve: { |
| 73 | type: "string", |
| 74 | }, |
| 75 | }, |
| 76 | strict: true, |
| 77 | allowPositionals: true, |
| 78 | }); |
| 79 | |
| 80 | if (!args.serve) { |
| 81 | console.error("Missing --serve, set it to the directory containing the frontend code"); |
| 82 | process.exit(1); |
| 83 | } |
| 84 | |
| 85 | export const mediaTypes = ["audio", "video"]; |
| 86 | export const allowedTypes = [...mediaTypes, "image"]; |
| 87 | |
| 88 | //simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically |
| 89 | export class TTLCache<V> { |
| 90 | private map = new Map<string, { value: V; timestamp: number }>(); |
| 91 | |
| 92 | constructor(private ttl: number) { |
| 93 | //periodic sweep to bound memory even for entries that are never accessed again |
| 94 | setInterval( |
| 95 | () => { |
| 96 | const now = Date.now(); |
| 97 | for (const [key, entry] of this.map.entries()) { |
| 98 | if (now - entry.timestamp > this.ttl) this.map.delete(key); |
| 99 | } |
| 100 | }, |
| 101 | 1000 * 60 * 60, |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | get(key: string): V | undefined { |
| 106 | const entry = this.map.get(key); |
| 107 | if (!entry) return undefined; |
| 108 | if (Date.now() - entry.timestamp > this.ttl) { |
| 109 | this.map.delete(key); |
| 110 | return undefined; |
| 111 | } |
| 112 | return entry.value; |
| 113 | } |
| 114 | |
| 115 | set(key: string, value: V): void { |
| 116 | this.map.set(key, { value, timestamp: Date.now() }); |
| 117 | } |
| 118 | |
| 119 | clear(): void { |
| 120 | this.map.clear(); |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | const oneDay = 1000 * 60 * 60 * 24; |
| 125 | export const fileTypeCache = new TTLCache<string>(oneDay); |
| 126 | export const probeCache = new TTLCache<Metadata>(oneDay); |
| 127 | export const generatedPlaylistIds = new Map<string, string[]>(); |
| 128 | |
| 129 | export class PathInfo { |
| 130 | constructor(public mimeType: string) {} |
| 131 | } |
| 132 | |
| 133 | export class ServerError { |
| 134 | constructor( |
| 135 | public status: number, |
| 136 | public error: string, |
| 137 | ) {} |
| 138 | } |
| 139 |