@fdm-monster/server
Version:
FDM Monster is a bulk OctoPrint, Klipper, PrusaLink and BambuLab manager to set up, configure and monitor 3D printers. Our aim is to provide neat overview over your farm.
326 lines (325 loc) • 11.9 kB
JavaScript
import { ConflictException } from "../exceptions/runtime.exceptions.js";
import { AppConstants } from "../server.constants.js";
import { getMediaPath } from "../utils/fs.utils.js";
import { PrintJob } from "../entities/print-job.entity.js";
import path, { basename, extname, join } from "node:path";
import { createReadStream, existsSync, statSync } from "node:fs";
import { access, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
//#region src/services/file-storage.service.ts
var FileStorageService = class FileStorageService {
printJobRepository;
logger;
storageBasePath;
STORAGE_SUBDIRS = [
"gcode",
"3mf",
"bgcode"
];
constructor(loggerFactory, typeormService) {
this.printJobRepository = typeormService.getDataSource().getRepository(PrintJob);
this.logger = loggerFactory(FileStorageService.name);
this.storageBasePath = join(getMediaPath(), AppConstants.defaultPrintFilesStorage);
}
async ensureStorageDirectories() {
try {
await mkdir(this.storageBasePath, { recursive: true });
for (const subdir of this.STORAGE_SUBDIRS) await mkdir(join(this.storageBasePath, subdir), { recursive: true });
} catch (error) {
this.logger.error("Failed to create storage directories", error);
}
}
readFileStream(fileStorageId) {
const stream = createReadStream(this.getFilePath(fileStorageId));
stream.on("error", (err) => {
this.logger.error(`Failed to read file ${fileStorageId}: ${err.message}`, err);
});
return stream;
}
getFileSize(fileStorageId) {
return statSync(this.getFilePath(fileStorageId)).size;
}
async validateUniqueFilename(fileName) {
const existing = await this.findDuplicateByOriginalFileName(fileName);
if (existing) throw new ConflictException(`A file named "${fileName}" already exists in storage. Please rename the file, delete the existing file (ID: ${existing.fileStorageId}), or choose a different name.`, existing.fileStorageId);
}
async saveFile(file, fileHash) {
const fileExt = extname(file.originalname).toLowerCase();
let fileId;
if (fileHash) {
const nameHash = createHash("sha256").update(fileHash + file.originalname).digest("hex").substring(0, 32);
fileId = `${nameHash.substring(0, 8)}-${nameHash.substring(8, 12)}-${nameHash.substring(12, 16)}-${nameHash.substring(16, 20)}-${nameHash.substring(20, 32)}`;
} else fileId = crypto.randomUUID();
let subdir = "gcode";
if (fileExt === ".3mf" || file.originalname.includes(".gcode.3mf")) subdir = "3mf";
else if (fileExt === ".bgcode") subdir = "bgcode";
const targetPath = join(join(this.storageBasePath, subdir), `${fileId}${fileExt}`);
if (file.path) await rename(file.path, targetPath);
else if (file.buffer) await writeFile(targetPath, file.buffer);
else throw new Error("File has no path or buffer");
this.logger.log(`Saved file ${file.originalname} as ${fileId}`);
return fileId;
}
async getFile(fileStorageId) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) throw new Error(`File ${fileStorageId} not found in storage`);
return readFile(filePath);
}
async deleteFile(fileStorageId) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) {
this.logger.warn(`File ${fileStorageId} not found, cannot delete`);
return;
}
await unlink(filePath);
const metadataPath = filePath + ".json";
try {
await unlink(metadataPath);
this.logger.debug(`Deleted metadata JSON for ${fileStorageId}`);
} catch {}
const thumbnailDir = filePath.replace(/\.(gcode|3mf|bgcode)$/i, "_thumbnails");
try {
await rm(thumbnailDir, {
recursive: true,
force: true
});
this.logger.debug(`Deleted thumbnails for ${fileStorageId}`);
} catch {}
this.logger.log(`Deleted file ${fileStorageId}`);
}
getFilePath(fileStorageId) {
for (const subdir of this.STORAGE_SUBDIRS) for (const ext of [
".gcode",
".3mf",
".bgcode",
""
]) {
const fullPath = join(this.storageBasePath, subdir, fileStorageId + ext);
if (existsSync(fullPath)) return fullPath;
}
return join(this.storageBasePath, "gcode", fileStorageId);
}
async calculateFileHash(filePath) {
const fileBuffer = await readFile(filePath);
const hashSum = createHash("sha256");
hashSum.update(fileBuffer);
return hashSum.digest("hex");
}
getDeterministicId(fileHash, fileName) {
const nameHash = createHash("sha256").update(fileHash + fileName).digest("hex").substring(0, 32);
return `${nameHash.substring(0, 8)}-${nameHash.substring(8, 12)}-${nameHash.substring(12, 16)}-${nameHash.substring(16, 20)}-${nameHash.substring(20, 32)}`;
}
async findFilePath(fileStorageId) {
for (const subdir of this.STORAGE_SUBDIRS) {
const dirPath = join(this.storageBasePath, subdir);
try {
const matchingFile = (await readdir(dirPath)).find((f) => f.startsWith(fileStorageId));
if (matchingFile) return join(dirPath, matchingFile);
} catch {}
}
return null;
}
async fileExists(fileStorageId) {
return await this.findFilePath(fileStorageId) !== null;
}
async findDuplicateByHash(fileHash) {
return this.printJobRepository.findOne({
where: { fileHash },
order: { createdAt: "DESC" }
});
}
async findDuplicateByOriginalFileName(originalFileName) {
for (const subdir of this.STORAGE_SUBDIRS) {
const dirPath = join(this.storageBasePath, subdir);
try {
const dirFiles = await readdir(dirPath);
for (const file of dirFiles) {
if (file.endsWith("_thumbnails") || file.endsWith(".json")) continue;
const fileId = path.parse(file).name;
const metadata = await this.loadMetadata(fileId);
if (metadata?._originalFileName === originalFileName) return {
fileStorageId: fileId,
metadata
};
}
} catch (error) {
this.logger.error(`Error searching for duplicate in ${subdir}`, error);
}
}
return null;
}
async saveMetadata(fileStorageId, metadata, fileHash, originalFileName, thumbnailMetadata) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) {
this.logger.warn(`Cannot save metadata - file ${fileStorageId} not found`);
return;
}
const metadataPath = filePath + ".json";
let existingOriginalFileName = originalFileName;
let existingThumbnails = thumbnailMetadata;
try {
const existingContent = await readFile(metadataPath, "utf8");
const existing = JSON.parse(existingContent);
if (existing._originalFileName && !originalFileName) existingOriginalFileName = existing._originalFileName;
if (existing._thumbnails && !thumbnailMetadata) existingThumbnails = existing._thumbnails;
} catch {}
const metadataWithMeta = {
...metadata,
_fileHash: fileHash || null,
_analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
_fileStorageId: fileStorageId,
_originalFileName: existingOriginalFileName || metadata.fileName || null,
_thumbnails: existingThumbnails || []
};
await writeFile(metadataPath, JSON.stringify(metadataWithMeta, null, 2), "utf8");
const thumbnailMeta = thumbnailMetadata ? ` with ${thumbnailMetadata.length} thumbnail(s)` : "";
this.logger.debug(`Saved metadata for ${fileStorageId}${thumbnailMeta}`);
}
async loadMetadata(fileStorageId) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) return null;
const metadataPath = filePath + ".json";
try {
const content = await readFile(metadataPath, "utf8");
return JSON.parse(content);
} catch {
return null;
}
}
async hasMetadata(fileStorageId) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) return false;
const metadataPath = filePath + ".json";
try {
await access(metadataPath);
return true;
} catch {
return false;
}
}
async saveThumbnails(fileStorageId, thumbnails) {
const savedThumbnails = [];
if (!thumbnails || thumbnails.length === 0) return savedThumbnails;
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) {
this.logger.warn(`Cannot save thumbnails - file ${fileStorageId} not found`);
return savedThumbnails;
}
const thumbnailDir = filePath.replace(/\.(gcode|3mf|bgcode)$/i, "_thumbnails");
try {
await rm(thumbnailDir, {
recursive: true,
force: true
});
this.logger.debug(`Cleared old thumbnails for ${fileStorageId}`);
} catch {}
await mkdir(thumbnailDir, { recursive: true });
for (let i = 0; i < thumbnails.length; i++) {
const thumb = thumbnails[i];
if (!thumb.data) continue;
const ext = thumb.format?.toLowerCase() || "png";
const filename = `thumb_${i}.${ext}`;
const thumbPath = join(thumbnailDir, filename);
try {
const buffer = Buffer.from(thumb.data, "base64");
await writeFile(thumbPath, buffer);
const relativePath = path.relative(this.storageBasePath, thumbPath);
savedThumbnails.push({
index: i,
path: relativePath,
filename,
width: thumb.width || 0,
height: thumb.height || 0,
format: ext,
size: buffer.length
});
this.logger.debug(`Saved thumbnail ${i} for ${fileStorageId} (${thumb.width}x${thumb.height}, ${buffer.length} bytes)`);
} catch (error) {
this.logger.warn(`Failed to save thumbnail ${i} for ${fileStorageId}: ${error}`);
}
}
return savedThumbnails;
}
async getThumbnail(fileStorageId, index) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) return null;
const thumbnailDir = filePath.replace(/\.(gcode|3mf|bgcode)$/i, "_thumbnails");
for (const ext of [
"png",
"jpg",
"jpeg",
"qoi"
]) {
const thumbPath = join(thumbnailDir, `thumb_${index}.${ext}`);
try {
return await readFile(thumbPath);
} catch {}
}
return null;
}
async listThumbnails(fileStorageId) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) return [];
const thumbnailDir = filePath.replace(/\.(gcode|3mf|bgcode)$/i, "_thumbnails");
try {
return (await readdir(thumbnailDir)).filter((f) => f.startsWith("thumb_")).sort((a, b) => a.localeCompare(b));
} catch {
return [];
}
}
async listAllFiles() {
const files = [];
for (const subdir of this.STORAGE_SUBDIRS) {
const dirPath = join(this.storageBasePath, subdir);
try {
const dirFiles = await readdir(dirPath);
for (const file of dirFiles) {
if (file.endsWith("_thumbnails") || file.endsWith(".json")) continue;
const fileId = path.parse(file).name;
const stats = await stat(join(dirPath, file));
const metadata = await this.loadMetadata(fileId);
const thumbnails = await this.listThumbnails(fileId);
files.push({
fileStorageId: fileId,
fileName: metadata?._fileName || file,
fileFormat: subdir,
fileSize: stats.size,
fileHash: metadata?._fileHash || "",
createdAt: stats.birthtime,
thumbnailCount: thumbnails.length,
metadata
});
}
} catch (error) {
this.logger.error(`Error listing files in ${subdir}`, error);
}
}
return files.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async getFileInfo(fileStorageId) {
const filePath = await this.findFilePath(fileStorageId);
if (!filePath) return null;
try {
const stats = await stat(filePath);
const metadata = await this.loadMetadata(fileStorageId);
const thumbnails = await this.listThumbnails(fileStorageId);
const ext = extname(filePath).substring(1);
return {
fileStorageId,
fileName: metadata?._originalFileName || basename(filePath),
fileFormat: ext,
fileSize: stats.size,
fileHash: metadata?._fileHash || "",
createdAt: stats.birthtime,
thumbnailCount: thumbnails.length,
metadata
};
} catch (error) {
this.logger.error(`Error getting file info for ${fileStorageId}`, error);
return null;
}
}
};
//#endregion
export { FileStorageService };
//# sourceMappingURL=file-storage.service.js.map