shared.ts
⎇
Raw
1import path from "node:path";
2import { parseArgs } from "node:util";
3import commandExists from "command-exists";
4import type { Metadata, VideoExtras } 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] = (() => {
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
29export 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
33export 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
44setInterval(
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
53if (!path.isAbsolute(musicRoot)) {
54 console.error(`Music root ${musicRoot} is not absolute`);
55 process.exit(1);
56}
57
58export 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
64export 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
71if (!commandExists.sync("ffmpeg")) {
72 console.error("Required command ffmpeg not found in PATH");
73 process.exit(1);
74}
75if (!commandExists.sync("tar")) {
76 console.error("Required command tar not found in PATH");
77 process.exit(1);
78}
79if (!commandExists.sync("avifenc")) {
80 console.error("Required command avifenc not found in PATH");
81 process.exit(1);
82}
83if (!commandExists.sync("mediainfo")) {
84 console.error("Required command mediainfo not found in PATH");
85 process.exit(1);
86}
87
88export 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
99if (!args.serve) {
100 console.error("Missing --serve, set it to the directory containing the frontend code");
101 process.exit(1);
102}
103
104export const mediaTypes = ["audio", "video"];
105export const allowedTypes = [...mediaTypes, "image"];
106
107//simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically
108export 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
143const oneDay = 1000 * 60 * 60 * 24;
144export const fileTypeCache = new TTLCache<string>(oneDay);
145export const probeCache = new TTLCache<Metadata>(oneDay);
146export const videoExtrasCache = new TTLCache<VideoExtras>(oneDay);
147export const generatedPlaylistIds = new Map<string, string[]>();
148
149export class PathInfo {
150 constructor(public mimeType: string) {}
151}
152
153export class ServerError {
154 constructor(
155 public status: number,
156 public error: string,
157 ) {}
158}
159