UNPKG

@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
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