shared.ts
⎇
Raw
1import path from "node:path";
2import { parseArgs } from "node:util";
3import commandExists from "command-exists";
4import type { Metadata } from "music-server-shared/types";
5
6export 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");
12export const musicRoot = process.env.MUSIC_ROOT || "";
13if (musicRoot === "") {
14 console.error("Please define the environment variable MUSIC_ROOT");
15 process.exit(1);
16}
17
18export const [username, password] = process.env.AUTH
19 ? Buffer.from(process.env.AUTH, "base64").toString().split(":", 2)
20 : [undefined, undefined];
21
22export const authTokens = new Map<string, Date>();
23
24//clear outdated tokens once a day
25setInterval(
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
34if (!path.isAbsolute(musicRoot)) {
35 console.error(`Music root ${musicRoot} is not absolute`);
36 process.exit(1);
37}
38
39export 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
45export 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
52if (!commandExists.sync("ffmpeg")) {
53 console.error("Required command ffmpeg not found in PATH");
54 process.exit(1);
55}
56if (!commandExists.sync("tar")) {
57 console.error("Required command tar not found in PATH");
58 process.exit(1);
59}
60if (!commandExists.sync("avifenc")) {
61 console.error("Required command avifenc not found in PATH");
62 process.exit(1);
63}
64if (!commandExists.sync("mediainfo")) {
65 console.error("Required command mediainfo not found in PATH");
66 process.exit(1);
67}
68
69export 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
80if (!args.serve) {
81 console.error("Missing --serve, set it to the directory containing the frontend code");
82 process.exit(1);
83}
84
85export const mediaTypes = ["audio", "video"];
86export const allowedTypes = [...mediaTypes, "image"];
87
88//simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically
89export 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
124const oneDay = 1000 * 60 * 60 * 24;
125export const fileTypeCache = new TTLCache<string>(oneDay);
126export const probeCache = new TTLCache<Metadata>(oneDay);
127export const generatedPlaylistIds = new Map<string, string[]>();
128
129export class PathInfo {
130 constructor(public mimeType: string) {}
131}
132
133export class ServerError {
134 constructor(
135 public status: number,
136 public error: string,
137 ) {}
138}
139