const http = require("node:http");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const path = require("node:path");
const crypto = require("node:crypto");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
const zlib = require("node:zlib");
const { DatabaseSync } = require("node:sqlite");
const ROOT = __dirname;
const PORT = Number(process.env.PORT || 3000);
const HOST = process.env.HOST || "127.0.0.1";
const LIBRARY_DIR = path.resolve(process.env.LIBRARY_DIR || path.join(ROOT, "Library"));
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, "data"));
const AUTH_USERNAME = process.env.AUTH_USERNAME || "";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "";
const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || "";
const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1);
const THUMB_DIR = path.join(DATA_DIR, "thumbnails");
const CACHE_DIR = path.join(DATA_DIR, "cache");
const DB_FILE = path.join(DATA_DIR, "library.sqlite");
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json");
const ANIDB_TITLES_CACHE = path.join(CACHE_DIR, "anidb-anime-titles.xml");
const ANILIST_URL = "https://graphql.anilist.co";
const ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz";
const ANIDB_HTTP_API_URL = "http://api.anidb.net:9001/httpapi";
const ANIDB_IMAGE_BASE_URL = "https://cdn.anidb.net/images/main/";
const FETCH_TIMEOUT_MS = 12000;
const ANIDB_TITLES_MAX_AGE_MS = 36 * 60 * 60 * 1000;
const VIDEO_EXTS = new Set([".mp4", ".m4v", ".mkv", ".webm", ".mov", ".avi"]);
const MIME_BY_EXT = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".webm": "video/webm",
".mov": "video/quicktime",
".avi": "video/x-msvideo"
};
let db;
let lastScan = [];
function send(res, status, body, type = "application/json") {
const payload = type === "application/json" ? JSON.stringify(body) : body;
res.writeHead(status, {
"content-type": `${type}; charset=utf-8`,
"cache-control": "no-store"
});
res.end(payload);
}
function unauthorized(res) {
res.writeHead(401, {
"www-authenticate": 'Basic realm="h-player"',
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store"
});
res.end("Authentication required");
}
function timingSafeEqualText(left, right) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function isAuthorized(req) {
if (!AUTH_USERNAME && !AUTH_PASSWORD) return true;
const header = req.headers.authorization || "";
if (!header.startsWith("Basic ")) return false;
let decoded = "";
try {
decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
} catch {
return false;
}
const separator = decoded.indexOf(":");
if (separator === -1) return false;
const username = decoded.slice(0, separator);
const password = decoded.slice(separator + 1);
return timingSafeEqualText(username, AUTH_USERNAME) && timingSafeEqualText(password, AUTH_PASSWORD);
}
function safeDecode(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isInside(parent, child) {
const rel = path.relative(parent, child);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
function slugFor(value) {
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 16);
}
async function ensureStorage() {
await fsp.mkdir(THUMB_DIR, { recursive: true });
await fsp.mkdir(CACHE_DIR, { recursive: true });
db = new DatabaseSync(DB_FILE);
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS title_metadata (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
thumbnail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
await migrateLegacyJson();
}
async function migrateLegacyJson() {
let legacy;
try {
legacy = JSON.parse(await fsp.readFile(LEGACY_META_FILE, "utf8"));
} catch (err) {
if (err.code === "ENOENT") return;
throw err;
}
const insert = db.prepare(`
INSERT OR IGNORE INTO title_metadata
(id, title, description, thumbnail, created_at, updated_at)
VALUES
(?, ?, ?, ?, ?, ?)
`);
const now = new Date().toISOString();
for (const [id, item] of Object.entries(legacy || {})) {
insert.run(
id,
String(item.title || "Untitled"),
String(item.description || ""),
String(item.thumbnail || ""),
String(item.createdAt || item.created_at || now),
String(item.updatedAt || item.updated_at || now)
);
}
}
function getTitleMetadata(id, folder) {
const existing = db.prepare("SELECT * FROM title_metadata WHERE id = ?").get(id);
if (existing) return normalizeMetadata(existing);
const now = new Date().toISOString();
db.prepare(`
INSERT INTO title_metadata
(id, title, description, thumbnail, created_at, updated_at)
VALUES
(?, ?, '', '', ?, ?)
`).run(id, naturalName(folder), now, now);
return {
id,
title: naturalName(folder),
description: "",
thumbnail: "",
createdAt: now,
updatedAt: now
};
}
function getExistingMetadata(id) {
const existing = db.prepare("SELECT * FROM title_metadata WHERE id = ?").get(id);
return existing ? normalizeMetadata(existing) : null;
}
function saveTitleMetadata(id, input) {
const current = getExistingMetadata(id);
if (!current) return null;
const next = {
title: String(input.title || "").trim() || current.title,
description: String(input.description || "").trim(),
thumbnail: String(input.thumbnail || "").trim(),
updatedAt: new Date().toISOString()
};
db.prepare(`
UPDATE title_metadata
SET title = ?, description = ?, thumbnail = ?, updated_at = ?
WHERE id = ?
`).run(next.title, next.description, next.thumbnail, next.updatedAt, id);
return { ...current, ...next };
}
function applyFetchedMetadata(id, fetched, { overwrite = false, generatedTitle = "" } = {}) {
const current = getExistingMetadata(id);
if (!current) return null;
const canReplaceGeneratedTitle = generatedTitle && normalizeSearch(current.title) === normalizeSearch(generatedTitle);
const next = {
title: overwrite || !current.title || canReplaceGeneratedTitle ? fetched.title || current.title : current.title,
description: overwrite || !current.description ? fetched.description || current.description : current.description,
thumbnail: overwrite || !current.thumbnail ? fetched.thumbnail || current.thumbnail : current.thumbnail,
updatedAt: new Date().toISOString()
};
db.prepare(`
UPDATE title_metadata
SET title = ?, description = ?, thumbnail = ?, updated_at = ?
WHERE id = ?
`).run(next.title, next.description, next.thumbnail, next.updatedAt, id);
return { ...current, ...next };
}
function setTitleThumbnail(id, thumbnail) {
const current = getExistingMetadata(id);
if (!current) return null;
const updatedAt = new Date().toISOString();
db.prepare("UPDATE title_metadata SET thumbnail = ?, updated_at = ? WHERE id = ?")
.run(thumbnail, updatedAt, id);
return { ...current, thumbnail, updatedAt };
}
function normalizeMetadata(row) {
return {
id: row.id,
title: row.title,
description: row.description,
thumbnail: row.thumbnail,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function naturalName(value) {
return value
.replace(/[._-]+/g, " ")
.replace(/\[[^\]]+\]|\([12][0-9]{3}\)/g, " ")
.replace(/\b(S\d{1,2}|Season\s+\d{1,2}|Episode\s+\d{1,3}|E\d{1,3})\b/gi, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function searchName(value) {
return naturalName(value)
.replace(/\b(uncensored|uncen|subbed|dubbed|sub|dub|ova|oad|specials?|batch|complete|web[- ]?dl|blu[- ]?ray|bdrip|webrip|x264|x265|h264|h265|hevc|aac|flac|1080p|720p|480p)\b/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizeSearch(value) {
return String(value || "")
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ");
}
async function walkVideos(dir, base = dir) {
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === "ENOENT") return [];
throw err;
}
const videos = [];
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
videos.push(...await walkVideos(fullPath, base));
continue;
}
if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
const relativePath = path.relative(base, fullPath).split(path.sep).join("/");
const stat = await fsp.stat(fullPath);
videos.push({
id: slugFor(relativePath),
name: path.basename(entry.name, path.extname(entry.name)),
fileName: entry.name,
relativePath,
size: stat.size,
mtime: stat.mtimeMs
});
}
}
return videos.sort((a, b) => a.relativePath.localeCompare(b.relativePath, undefined, { numeric: true }));
}
async function scanLibrary() {
const titles = [];
let roots;
try {
roots = await fsp.readdir(LIBRARY_DIR, { withFileTypes: true });
} catch (err) {
if (err.code === "ENOENT") {
lastScan = [];
return lastScan;
}
throw err;
}
for (const entry of roots) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
const titlePath = path.join(LIBRARY_DIR, entry.name);
const videos = await walkVideos(titlePath);
if (!videos.length) continue;
const id = slugFor(entry.name);
const meta = getTitleMetadata(id, entry.name);
const seasons = groupSeasons(videos);
titles.push({
id,
folder: entry.name,
title: meta.title || naturalName(entry.name),
description: meta.description || "",
thumbnail: meta.thumbnail || "",
count: videos.length,
size: videos.reduce((sum, video) => sum + video.size, 0),
updatedAt: Math.max(...videos.map((video) => video.mtime)),
seasons,
videos
});
}
titles.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
lastScan = titles;
return lastScan;
}
function groupSeasons(videos) {
const groups = new Map();
for (const video of videos) {
const parts = video.relativePath.split("/");
const season = parts.length > 1 ? parts[0] : "Movies / Specials";
if (!groups.has(season)) groups.set(season, []);
groups.get(season).push(video);
}
return [...groups.entries()].map(([name, items]) => ({ name, videos: items }));
}
function findVideo(videoId) {
for (const title of lastScan) {
const video = title.videos.find((item) => item.id === videoId);
if (video) return { title, video };
}
return null;
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8");
}
async function generateThumbnail(titleId) {
const title = lastScan.find((item) => item.id === titleId);
if (!title || !title.videos.length) {
const error = new Error("Title not found");
error.status = 404;
throw error;
}
const source = path.resolve(LIBRARY_DIR, title.folder, title.videos[0].relativePath);
if (!isInside(LIBRARY_DIR, source)) throw new Error("Invalid video path");
const outputName = `${titleId}.jpg`;
const output = path.join(THUMB_DIR, outputName);
await new Promise((resolve, reject) => {
const ffmpeg = spawn("ffmpeg", [
"-y",
"-ss", "00:00:08",
"-i", source,
"-frames:v", "1",
"-vf", "scale=640:-1",
output
], { stdio: "ignore" });
ffmpeg.on("error", reject);
ffmpeg.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error("ffmpeg could not generate a thumbnail"));
});
});
const meta = setTitleThumbnail(titleId, `/thumbs/${outputName}`);
await scanLibrary();
return meta;
}
async function fetchText(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"user-agent": "h-player/1.3 metadata fetcher",
"accept": "application/json, application/xml, text/xml, */*",
...(options.headers || {})
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
}
async function fetchBuffer(url) {
const response = await fetch(url, {
headers: { "user-agent": "h-player/1.3 metadata fetcher" },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
function decodeHtml(value) {
return String(value || "")
.replace(/
/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/"/g, '"')
.replace(/'|'/g, "'")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function scoreTitle(candidate, query) {
const left = normalizeSearch(candidate);
const right = normalizeSearch(query);
if (!left || !right) return 0;
if (left === right) return 100;
if (left.includes(right) || right.includes(left)) return 82;
const leftWords = new Set(left.split(" "));
const rightWords = right.split(" ");
const hits = rightWords.filter((word) => leftWords.has(word)).length;
return Math.round((hits / Math.max(rightWords.length, leftWords.size)) * 70);
}
function mediaTitle(media) {
return media?.title?.english || media?.title?.romaji || media?.title?.native || "";
}
function mediaDescription(media) {
const parts = [];
const description = decodeHtml(media?.description || "");
if (description) parts.push(description);
const meta = [
media?.format,
media?.seasonYear,
media?.episodes ? `${media.episodes} episodes` : "",
media?.averageScore ? `${media.averageScore}% AniList score` : ""
].filter(Boolean);
if (meta.length) parts.push(meta.join(" ยท "));
return parts.join("\n\n");
}
async function fetchAniListMetadata(title) {
const query = `
query ($search: String, $isAdult: Boolean) {
Page(page: 1, perPage: 6) {
media(type: ANIME, search: $search, isAdult: $isAdult, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
id
title { romaji english native }
description(asHtml: false)
episodes
format
seasonYear
averageScore
isAdult
coverImage { extraLarge large }
}
}
}
`;
for (const isAdult of [false, true]) {
const raw = await fetchText(ANILIST_URL, {
method: "POST",
headers: { "content-type": "application/json", "accept": "application/json" },
body: JSON.stringify({ query, variables: { search: title, isAdult } })
});
const json = JSON.parse(raw);
if (json.errors?.length) throw new Error(json.errors[0].message || "AniList request failed");
const media = (json.data?.Page?.media || [])
.map((item) => ({ item, score: Math.max(
scoreTitle(item.title?.romaji, title),
scoreTitle(item.title?.english, title),
scoreTitle(item.title?.native, title)
) }))
.sort((a, b) => b.score - a.score)[0];
if (media && media.score >= 45) {
return {
provider: "anilist",
providerId: String(media.item.id),
title: mediaTitle(media.item),
description: mediaDescription(media.item),
thumbnail: media.item.coverImage?.extraLarge || media.item.coverImage?.large || "",
matchScore: media.score
};
}
}
return null;
}
async function readAniDbTitlesXml() {
try {
const stat = await fsp.stat(ANIDB_TITLES_CACHE);
if (Date.now() - stat.mtimeMs < ANIDB_TITLES_MAX_AGE_MS) {
return fsp.readFile(ANIDB_TITLES_CACHE, "utf8");
}
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
const compressed = await fetchBuffer(ANIDB_TITLES_URL);
const xml = zlib.gunzipSync(compressed).toString("utf8");
await fsp.writeFile(ANIDB_TITLES_CACHE, xml);
return xml;
}
function parseAniDbTitles(xml) {
const byAid = new Map();
const animePattern = /([\s\S]*?)<\/anime>/g;
let animeMatch;
while ((animeMatch = animePattern.exec(xml))) {
const aid = animeMatch[1];
const titles = [];
const titlePattern = /]*type="([^"]+)"[^>]*>([\s\S]*?)<\/title>/g;
let titleMatch;
while ((titleMatch = titlePattern.exec(animeMatch[2]))) {
titles.push({ type: titleMatch[1], value: decodeHtml(titleMatch[2]) });
}
if (titles.length) byAid.set(aid, titles);
}
return byAid;
}
function findAniDbMatch(titlesByAid, query) {
let best = null;
for (const [aid, titles] of titlesByAid.entries()) {
for (const title of titles) {
const bonus = title.type === "main" ? 10 : title.type === "official" ? 5 : 0;
const score = scoreTitle(title.value, query) + bonus;
if (!best || score > best.score) best = { aid, title: title.value, score, titles };
}
}
return best && best.score >= 48 ? best : null;
}
function xmlValue(xml, tag) {
const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
return match ? decodeHtml(match[1]) : "";
}
async function fetchAniDbAnimeDetails(aid) {
if (!ANIDB_CLIENT_NAME || !ANIDB_CLIENT_VERSION) return null;
const params = new URLSearchParams({
request: "anime",
client: ANIDB_CLIENT_NAME,
clientver: String(ANIDB_CLIENT_VERSION),
protover: "1",
aid
});
const xml = await fetchText(`${ANIDB_HTTP_API_URL}?${params.toString()}`, {
headers: { "accept": "application/xml, text/xml, */*" }
});
if (/ item.type === "main")?.value || match.title;
return {
provider: details ? "anidb" : "anidb-titles",
providerId: match.aid,
title: mainTitle,
description: details?.description || "",
thumbnail: details?.thumbnail || "",
matchScore: match.score
};
}
async function fetchTitleMetadata(titleId) {
const title = lastScan.find((item) => item.id === titleId);
if (!title) {
const error = new Error("Title not found");
error.status = 404;
throw error;
}
const query = searchName(title.folder || title.title);
const attempts = [];
try {
const anilist = await fetchAniListMetadata(query);
if (anilist && (anilist.description || anilist.thumbnail)) {
const metadata = applyFetchedMetadata(titleId, anilist, { generatedTitle: naturalName(title.folder) });
await scanLibrary();
return { metadata, fetched: anilist, attempts: ["anilist"] };
}
attempts.push("AniList had no usable match");
} catch (err) {
attempts.push(`AniList failed: ${err.message}`);
}
try {
const anidb = await fetchAniDbMetadata(query);
if (anidb) {
const metadata = applyFetchedMetadata(titleId, anidb, { generatedTitle: naturalName(title.folder) });
await scanLibrary();
return { metadata, fetched: anidb, attempts };
}
attempts.push("AniDB had no title match");
} catch (err) {
attempts.push(`AniDB failed: ${err.message}`);
}
const error = new Error(`No metadata found. ${attempts.join("; ")}`);
error.status = 404;
throw error;
}
async function serveStatic(req, res, pathname) {
const filePath = path.resolve(ROOT, "public", pathname.replace(/^\/+/, ""));
if (!isInside(path.join(ROOT, "public"), filePath)) return send(res, 403, { error: "Forbidden" });
try {
const data = await fsp.readFile(filePath);
const ext = path.extname(filePath);
const types = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".svg": "image/svg+xml"
};
send(res, 200, data, types[ext] || "application/octet-stream");
} catch (err) {
if (err.code === "ENOENT") send(res, 404, { error: "Not found" });
else throw err;
}
}
async function serveThumbnail(res, name) {
const filePath = path.resolve(THUMB_DIR, safeDecode(name));
if (!isInside(THUMB_DIR, filePath)) return send(res, 403, { error: "Forbidden" });
try {
const data = await fsp.readFile(filePath);
res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "private, max-age=3600" });
res.end(data);
} catch (err) {
if (err.code === "ENOENT") send(res, 404, { error: "Not found" });
else throw err;
}
}
async function streamVideo(req, res, videoId) {
const match = findVideo(videoId);
if (!match) return send(res, 404, { error: "Video not found" });
const filePath = path.resolve(LIBRARY_DIR, match.title.folder, match.video.relativePath);
if (!isInside(LIBRARY_DIR, filePath)) return send(res, 403, { error: "Forbidden" });
const stat = await fsp.stat(filePath);
const range = req.headers.range;
const type = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || "application/octet-stream";
if (!range) {
res.writeHead(200, {
"content-length": stat.size,
"content-type": type,
"accept-ranges": "bytes"
});
fs.createReadStream(filePath).pipe(res);
return;
}
const [startRaw, endRaw] = range.replace(/bytes=/, "").split("-");
const start = Number(startRaw);
const end = endRaw ? Math.min(Number(endRaw), stat.size - 1) : stat.size - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start > end) {
res.writeHead(416, { "content-range": `bytes */${stat.size}` });
res.end();
return;
}
res.writeHead(206, {
"content-range": `bytes ${start}-${end}/${stat.size}`,
"accept-ranges": "bytes",
"content-length": end - start + 1,
"content-type": type
});
fs.createReadStream(filePath, { start, end }).pipe(res);
}
async function route(req, res) {
if (!isAuthorized(req)) return unauthorized(res);
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
if (pathname === "/" || pathname === "/index.html") return serveStatic(req, res, "/index.html");
if (pathname.startsWith("/assets/")) return serveStatic(req, res, pathname);
if (pathname.startsWith("/thumbs/")) return serveThumbnail(res, pathname.slice("/thumbs/".length));
if (pathname === "/api/library" && req.method === "GET") {
return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() });
}
if (pathname === "/api/rescan" && req.method === "POST") {
return send(res, 200, { titles: await scanLibrary() });
}
const metaMatch = pathname.match(/^\/api\/titles\/([^/]+)$/);
if (metaMatch && req.method === "PATCH") {
const id = safeDecode(metaMatch[1]);
if (!getExistingMetadata(id)) return send(res, 404, { error: "Title not found" });
const input = JSON.parse(await readBody(req) || "{}");
const meta = saveTitleMetadata(id, input);
await scanLibrary();
return send(res, 200, meta);
}
const thumbMatch = pathname.match(/^\/api\/titles\/([^/]+)\/thumbnail$/);
if (thumbMatch && req.method === "POST") {
return send(res, 200, await generateThumbnail(safeDecode(thumbMatch[1])));
}
const metadataMatch = pathname.match(/^\/api\/titles\/([^/]+)\/metadata$/);
if (metadataMatch && req.method === "POST") {
return send(res, 200, await fetchTitleMetadata(safeDecode(metadataMatch[1])));
}
const videoMatch = pathname.match(/^\/video\/([^/]+)$/);
if (videoMatch && req.method === "GET") {
return streamVideo(req, res, safeDecode(videoMatch[1]));
}
return send(res, 404, { error: "Not found" });
}
ensureStorage()
.then(scanLibrary)
.then(() => {
const server = http.createServer((req, res) => {
route(req, res).catch((err) => {
console.error(err);
send(res, err.status || 500, { error: err.message || "Server error" });
});
});
server.on("error", (err) => {
console.error(`Could not start server: ${err.message}`);
process.exit(1);
});
server.listen(PORT, HOST, () => {
console.log(`h-player listening on http://${HOST}:${PORT}`);
console.log(`Library: ${LIBRARY_DIR}`);
console.log(`Database: ${DB_FILE}`);
if (AUTH_USERNAME || AUTH_PASSWORD) console.log("Basic authentication is enabled");
});
})
.catch((err) => {
console.error(err);
process.exit(1);
});