shared.ts
| 1 | import path from "node:path"; |
| 2 | import { parseArgs } from "node:util"; |
| 3 | import commandExists from "command-exists"; |
| 4 | import type { Metadata, VideoExtras } 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] = (() => { |
| 19 | if (!process.env.AUTH) return [undefined, undefined]; |
| 20 | const decoded = Buffer.from(process.env.AUTH, "base64").toString(); |
| 21 | const separator = decoded.indexOf(":"); |
| 22 | if (separator <= 0 || separator === decoded.length - 1) { |
| 23 | console.error("AUTH is set but invalid: expected base64 of 'username:password' with both parts non-empty"); |
| 24 | process.exit(1); |
| 25 | } |
| 26 | return [decoded.slice(0, separator), decoded.slice(separator + 1)]; |
| 27 | })(); |
| 28 | |
| 29 | export const authTokens = new Map<string, Date>(); |
| 30 | |
| 31 | //checks presence *and* expiry - the daily sweep below only bounds memory, it is not an |
| 32 | //enforcement mechanism, so a token must not stay valid past its end date while awaiting it |
| 33 | export function isValidAuthToken(token: string): boolean { |
| 34 | const endDate = authTokens.get(token); |
| 35 | if (!endDate) return false; |
| 36 | if (endDate.getTime() < Date.now()) { |
| 37 | authTokens.delete(token); |
| 38 | return false; |
| 39 | } |
| 40 | return true; |
| 41 | } |
| 42 | |
| 43 | //clear outdated tokens once a day |
| 44 | setInterval( |
| 45 | () => { |
| 46 | for (const [token, endDate] of authTokens.entries()) { |
| 47 | if (endDate.getTime() < Date.now()) authTokens.delete(token); |
| 48 | } |
| 49 | }, |
| 50 | 1000 * 60 * 60 * 24, |
| 51 | ); |
| 52 | |
| 53 | if (!path.isAbsolute(musicRoot)) { |
| 54 | console.error(`Music root ${musicRoot} is not absolute`); |
| 55 | process.exit(1); |
| 56 | } |
| 57 | |
| 58 | export const excludeExtension = |
| 59 | process.env.EXCLUDE_EXTENSION === undefined |
| 60 | ? ["txt", "log", "nfo", "m3u", "htm", "html"] |
| 61 | : process.env.EXCLUDE_EXTENSION.split(","); |
| 62 | |
| 63 | //number of parallel mediainfo processes used while scanning uncached folders |
| 64 | export const scanConcurrency = (() => { |
| 65 | if (process.env.SCAN_CONCURRENCY === undefined) return 8; |
| 66 | const parsed = Number.parseInt(process.env.SCAN_CONCURRENCY, 10); |
| 67 | if (Number.isNaN(parsed) || parsed <= 0) return 8; |
| 68 | return parsed; |
| 69 | })(); |
| 70 | |
| 71 | if (!commandExists.sync("ffmpeg")) { |
| 72 | console.error("Required command ffmpeg not found in PATH"); |
| 73 | process.exit(1); |
| 74 | } |
| 75 | if (!commandExists.sync("tar")) { |
| 76 | console.error("Required command tar not found in PATH"); |
| 77 | process.exit(1); |
| 78 | } |
| 79 | if (!commandExists.sync("avifenc")) { |
| 80 | console.error("Required command avifenc not found in PATH"); |
| 81 | process.exit(1); |
| 82 | } |
| 83 | if (!commandExists.sync("mediainfo")) { |
| 84 | console.error("Required command mediainfo not found in PATH"); |
| 85 | process.exit(1); |
| 86 | } |
| 87 | |
| 88 | export const { values: args } = parseArgs({ |
| 89 | args: Bun.argv, |
| 90 | options: { |
| 91 | serve: { |
| 92 | type: "string", |
| 93 | }, |
| 94 | }, |
| 95 | strict: true, |
| 96 | allowPositionals: true, |
| 97 | }); |
| 98 | |
| 99 | if (!args.serve) { |
| 100 | console.error("Missing --serve, set it to the directory containing the frontend code"); |
| 101 | process.exit(1); |
| 102 | } |
| 103 | |
| 104 | export const mediaTypes = ["audio", "video"]; |
| 105 | export const allowedTypes = [...mediaTypes, "image"]; |
| 106 | |
| 107 | //simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically |
| 108 | export class TTLCache<V> { |
| 109 | private map = new Map<string, { value: V; timestamp: number }>(); |
| 110 | |
| 111 | constructor(private ttl: number) { |
| 112 | //periodic sweep to bound memory even for entries that are never accessed again |
| 113 | setInterval( |
| 114 | () => { |
| 115 | const now = Date.now(); |
| 116 | for (const [key, entry] of this.map.entries()) { |
| 117 | if (now - entry.timestamp > this.ttl) this.map.delete(key); |
| 118 | } |
| 119 | }, |
| 120 | 1000 * 60 * 60, |
| 121 | ); |
| 122 | } |
| 123 | |
| 124 | get(key: string): V | undefined { |
| 125 | const entry = this.map.get(key); |
| 126 | if (!entry) return undefined; |
| 127 | if (Date.now() - entry.timestamp > this.ttl) { |
| 128 | this.map.delete(key); |
| 129 | return undefined; |
| 130 | } |
| 131 | return entry.value; |
| 132 | } |
| 133 | |
| 134 | set(key: string, value: V): void { |
| 135 | this.map.set(key, { value, timestamp: Date.now() }); |
| 136 | } |
| 137 | |
| 138 | clear(): void { |
| 139 | this.map.clear(); |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | const oneDay = 1000 * 60 * 60 * 24; |
| 144 | export const fileTypeCache = new TTLCache<string>(oneDay); |
| 145 | export const probeCache = new TTLCache<Metadata>(oneDay); |
| 146 | export const videoExtrasCache = new TTLCache<VideoExtras>(oneDay); |
| 147 | export const generatedPlaylistIds = new Map<string, string[]>(); |
| 148 | |
| 149 | export class PathInfo { |
| 150 | constructor(public mimeType: string) {} |
| 151 | } |
| 152 | |
| 153 | export class ServerError { |
| 154 | constructor( |
| 155 | public status: number, |
| 156 | public error: string, |
| 157 | ) {} |
| 158 | } |
| 159 |