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 MediaInfo instances / parallel probes 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}
64
65export const { values: args } = parseArgs({
66 args: Bun.argv,
67 options: {
68 serve: {
69 type: "string",
70 },
71 },
72 strict: true,
73 allowPositionals: true,
74});
75
76if (!args.serve) {
77 console.error("Missing --serve, set it to the directory containing the frontend code");
78 process.exit(1);
79}
80
81export const mediaTypes = ["audio", "video"];
82export const allowedTypes = [...mediaTypes, "image"];
83
84//simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically
85export class TTLCache<V> {
86 private map = new Map<string, { value: V; timestamp: number }>();
87
88 constructor(private ttl: number) {
89 //periodic sweep to bound memory even for entries that are never accessed again
90 setInterval(
91 () => {
92 const now = Date.now();
93 for (const [key, entry] of this.map.entries()) {
94 if (now - entry.timestamp > this.ttl) this.map.delete(key);
95 }
96 },
97 1000 * 60 * 60,
98 );
99 }
100
101 get(key: string): V | undefined {
102 const entry = this.map.get(key);
103 if (!entry) return undefined;
104 if (Date.now() - entry.timestamp > this.ttl) {
105 this.map.delete(key);
106 return undefined;
107 }
108 return entry.value;
109 }
110
111 set(key: string, value: V): void {
112 this.map.set(key, { value, timestamp: Date.now() });
113 }
114
115 clear(): void {
116 this.map.clear();
117 }
118}
119
120const oneDay = 1000 * 60 * 60 * 24;
121export const fileTypeCache = new TTLCache<string>(oneDay);
122export const probeCache = new TTLCache<Metadata>(oneDay);
123export const generatedPlaylistIds = new Map<string, string[]>();
124
125export class PathInfo {
126 constructor(public mimeType: string) {}
127}
128
129export class ServerError {
130 constructor(
131 public status: number,
132 public error: string,
133 ) {}
134}
135