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.

445 lines (444 loc) 16.2 kB
import { NotFoundException } from "../../exceptions/runtime.exceptions.js"; import { PrintJob } from "../../entities/print-job.entity.js"; import { calculateJobDuration, updateStatisticsForCancellation, updateStatisticsForCompletion, updateStatisticsForFailure } from "../../utils/job-stats.util.js"; //#region src/services/orm/print-job.service.ts var PrintJobService = class PrintJobService { printJobRepository; eventEmitter2; logger; constructor(loggerFactory, typeormService, eventEmitter2) { this.printJobRepository = typeormService.getDataSource().getRepository(PrintJob); this.eventEmitter2 = eventEmitter2; this.logger = loggerFactory(PrintJobService.name); } async getJobByIdOrFail(id, relations) { const job = await this.printJobRepository.findOne({ where: { id }, relations }); if (!job) throw new NotFoundException(`Job ${id} not found`); return job; } async handleFileAnalyzed(jobId, metadata, thumbnails) { const job = await this.printJobRepository.findOne({ where: { id: jobId } }); if (!job) throw new Error(`Print job ${jobId} not found`); job.metadata = metadata; job.analysisState = "ANALYZED"; job.analyzedAt = /* @__PURE__ */ new Date(); job.fileFormat = metadata.fileFormat; await this.printJobRepository.save(job); this.eventEmitter2.emit("printJob.analyzed", { jobId: job.id, printerId: job.printerId, metadata }); this.logger.log(`Print job ${jobId} analyzed: ${metadata.fileName}`); return job; } async handlePrintStarted(printerId, fileName, jobId, printerName) { const existingJob = await this.printJobRepository.findOne({ where: { printerId, status: "PRINTING" }, order: { startedAt: "DESC" } }); if (existingJob?.fileName === fileName && !jobId) return existingJob; if (existingJob && existingJob.fileName !== fileName && !jobId) { existingJob.status = "UNKNOWN"; existingJob.statusReason = "Print state unknown - printer started new job while previous job was still marked as printing. This may indicate a disconnect, server restart, or manual printer control."; existingJob.endedAt = /* @__PURE__ */ new Date(); await this.printJobRepository.save(existingJob); this.logger.warn(`Printer ${printerId} started new print "${fileName}" while job ${existingJob.id} was PRINTING "${existingJob.fileName}". Marked job ${existingJob.id} as UNKNOWN.`); } let job; if (jobId) { job = await this.printJobRepository.findOne({ where: { id: jobId } }); if (!job) throw new Error(`Print job ${jobId} not found`); } else { job = await this.printJobRepository.findOne({ where: { printerId, fileName, status: "PENDING" }, order: { createdAt: "DESC" } }); if (!job) job = await this.printJobRepository.findOne({ where: { printerId, fileName }, order: { createdAt: "DESC" } }); if (!job) { this.logger.log(`Creating new job for ${fileName} - no pending or existing job found`); job = this.printJobRepository.create({ printerId, printerName: printerName || null, fileName, status: "PRINTING", analysisState: "NOT_ANALYZED" }); } else if (job.status === "PENDING") this.logger.log(`Promoting pending job ${job.id} to PRINTING`); else if (job.status === "COMPLETED" || job.status === "FAILED" || job.status === "CANCELLED") { this.logger.log(`Creating new job for re-print of ${fileName} (previous job ${job.id} was ${job.status})`); job = this.printJobRepository.create({ printerId, printerName: printerName || job.printerName || null, fileName, status: "PRINTING", analysisState: job.analysisState, metadata: job.metadata, fileFormat: job.fileFormat }); } } job.status = "PRINTING"; job.startedAt = /* @__PURE__ */ new Date(); job.progress = 0; if (printerName && !job.printerName) job.printerName = printerName; if (!job.statistics) job.statistics = { startedAt: /* @__PURE__ */ new Date(), endedAt: null, actualPrintTimeSeconds: null, progress: 0 }; else { job.statistics.startedAt = /* @__PURE__ */ new Date(); job.statistics.progress = 0; } await this.printJobRepository.save(job); this.eventEmitter2.emit("printJob.started", { jobId: job.id, printerId, fileName, startedAt: job.startedAt }); this.logger.log(`Print job ${job.id} started on printer ${printerId}: ${fileName}`); return job; } async handlePrintProgress(printerId, progress) { const job = await this.printJobRepository.findOne({ where: { printerId, status: "PRINTING" }, order: { startedAt: "DESC" } }); if (!job) return null; job.progress = Math.min(100, Math.max(0, progress)); if (job.statistics) job.statistics.progress = job.progress; else job.statistics = { startedAt: job.startedAt, endedAt: null, actualPrintTimeSeconds: null, progress: job.progress }; await this.printJobRepository.save(job); this.eventEmitter2.emit("printJob.progress", { jobId: job.id, printerId, progress: job.progress }); return job; } async handlePrintCompleted(printerId, fileName) { const job = await this.printJobRepository.findOne({ where: { printerId, status: "PRINTING" }, order: { startedAt: "DESC" } }); if (!job) { this.logger.warn(`No active print job found for printer ${printerId} on completion`); return null; } if (fileName && job.fileName !== fileName) this.logger.warn(`Filename mismatch on completion: expected "${job.fileName}", got "${fileName}"`); const endedAt = /* @__PURE__ */ new Date(); const actualTimeSeconds = calculateJobDuration(job.startedAt, endedAt); job.status = "COMPLETED"; updateStatisticsForCompletion(job, endedAt); await this.printJobRepository.save(job); this.eventEmitter2.emit("printJob.completed", { jobId: job.id, printerId, fileName: job.fileName, actualTimeSeconds, estimatedTimeSeconds: job.metadata?.gcodePrintTimeSeconds }); this.logger.log(`Print job ${job.id} completed on printer ${printerId}: ${job.fileName} (${actualTimeSeconds?.toFixed(0)}s actual, ${job.metadata?.gcodePrintTimeSeconds}s estimated)`); return job; } async handlePrintFailed(printerId, reason, fileName) { const job = await this.printJobRepository.findOne({ where: { printerId, status: "PRINTING" }, order: { startedAt: "DESC" } }); if (!job) { this.logger.warn(`No active print job found for printer ${printerId} on failure`); return null; } const endedAt = /* @__PURE__ */ new Date(); job.status = "FAILED"; updateStatisticsForFailure(job, reason, endedAt); await this.printJobRepository.save(job); this.eventEmitter2.emit("printJob.failed", { jobId: job.id, printerId, fileName: job.fileName, reason, failedAt: endedAt }); this.logger.log(`Print job ${job.id} failed on printer ${printerId}: ${job.fileName} - ${reason}`); return job; } async handlePrintCancelled(printerId, reason) { const job = await this.printJobRepository.findOne({ where: { printerId, status: "PRINTING" }, order: { startedAt: "DESC" } }); if (!job) { this.logger.warn(`No active print job found for printer ${printerId} on cancellation`); return null; } const endedAt = /* @__PURE__ */ new Date(); job.status = "CANCELLED"; updateStatisticsForCancellation(job, reason, endedAt); await this.printJobRepository.save(job); this.eventEmitter2.emit("printJob.cancelled", { jobId: job.id, printerId, fileName: job.fileName, cancelledAt: endedAt }); this.logger.log(`Print job ${job.id} cancelled on printer ${printerId}: ${job.fileName}`); return job; } async handlePrintPaused(printerId) { const job = await this.printJobRepository.findOne({ where: { printerId, status: "PRINTING" }, order: { startedAt: "DESC" } }); if (!job) { this.logger.warn(`No active print job found for printer ${printerId} on pause`); return null; } job.status = "PAUSED"; await this.printJobRepository.save(job); this.logger.log(`Print job ${job.id} paused on printer ${printerId}`); return job; } async handlePrintResumed(printerId) { const job = await this.printJobRepository.findOne({ where: { printerId, status: "PAUSED" }, order: { startedAt: "DESC" } }); if (!job) { this.logger.warn(`No paused print job found for printer ${printerId} on resume`); return null; } job.status = "PRINTING"; await this.printJobRepository.save(job); this.logger.log(`Print job ${job.id} resumed on printer ${printerId}`); return job; } async cleanupStaleJobs() { const staleJobs = await this.printJobRepository.find({ where: { status: "PRINTING" } }); for (const job of staleJobs) { job.status = "UNKNOWN"; job.statusReason = "Print state unknown after server restart. The print may have completed, failed, or still be running. Check your printer for current status."; await this.printJobRepository.save(job); this.logger.warn(`Marked job ${job.id} (printer ${job.printerId}) as UNKNOWN after startup - was PRINTING before server stopped`); } if (staleJobs.length > 0) this.logger.log(`Cleaned up ${staleJobs.length} stale print job(s) on startup`); } async getActivePrintJob(printerId) { return this.printJobRepository.findOne({ where: { printerId, status: "PRINTING" }, order: { startedAt: "DESC" } }); } async getPrintJobHistory(printerId, limit = 50) { return this.printJobRepository.find({ where: { printerId }, order: { createdAt: "DESC" }, take: limit }); } async markStarted(printerId, fileName, printerName) { return await this.handlePrintStarted(printerId, fileName, void 0, printerName); } async markProgress(printerId, fileName, progress) { return await this.handlePrintProgress(printerId, progress); } /** * Mark print as finished by fileName * Used by printer middleware when print completes successfully */ async markFinished(printerId, fileName) { return await this.handlePrintCompleted(printerId, fileName); } /** * Mark print as failed by fileName * Used by printer middleware when print fails */ async markFailed(printerId, fileName, reason) { return await this.handlePrintFailed(printerId, reason, fileName); } /** * Update job metadata with partial data from printer middleware * Useful for updating estimates from OctoPrint/Moonraker during print * Only updates if job has no metadata or to supplement existing metadata */ async updateJobMetadata(printerId, fileName, partialMetadata) { const job = await this.printJobRepository.findOne({ where: { printerId, fileName, status: "PRINTING" }, order: { startedAt: "DESC" } }); if (!job) { this.logger.debug(`No active job found for printer ${printerId}, file ${fileName} - skipping metadata update`); return; } if (job.analysisState === "ANALYZED" && job.metadata) { this.logger.debug(`Job ${job.id} already has analyzed metadata, merging only missing fields`); const updatedMetadata = { ...job.metadata }; for (const [key, value] of Object.entries(partialMetadata)) if (value != null && (updatedMetadata[key] == null || updatedMetadata[key] === null)) updatedMetadata[key] = value; job.metadata = updatedMetadata; } else if (job.metadata) job.metadata = { ...job.metadata, ...partialMetadata }; else { if (!Object.values(partialMetadata).some((v) => v !== null)) { this.logger.debug(`Skipping metadata creation for job ${job.id} - no meaningful data provided`); return; } job.metadata = { fileName, fileFormat: job.fileFormat || "gcode", ...partialMetadata }; } await this.printJobRepository.save(job); this.logger.debug(`Updated metadata for job ${job.id}`); } /** * Search print jobs with optional filters */ async searchPrintJobs(searchPrinter, searchFile, startDate, endDate) { const query = this.printJobRepository.createQueryBuilder("job"); if (searchPrinter) query.andWhere("job.printerId = :printerId", { printerId: Number.parseInt(searchPrinter, 10) }); if (searchFile) query.andWhere("job.fileName LIKE :fileName", { fileName: `%${searchFile}%` }); if (startDate) query.andWhere("job.startedAt >= :startDate", { startDate }); if (endDate) query.andWhere("job.startedAt <= :endDate", { endDate }); return await query.orderBy("job.startedAt", "DESC").getMany(); } /** * Search print jobs with pagination */ async searchPrintJobsPaged(searchPrinter, searchFile, startDate, endDate, page = 1, pageSize = 50) { const query = this.printJobRepository.createQueryBuilder("job"); if (searchPrinter) query.andWhere("job.printerId = :printerId", { printerId: Number.parseInt(searchPrinter, 10) }); if (searchFile) query.andWhere("job.fileName LIKE :fileName", { fileName: `%${searchFile}%` }); if (startDate) query.andWhere("job.startedAt >= :startDate", { startDate }); if (endDate) query.andWhere("job.startedAt <= :endDate", { endDate }); return await query.orderBy("job.startedAt", "DESC").skip((page - 1) * pageSize).take(pageSize).getManyAndCount(); } /** * Create a pending print job (typically called when file is uploaded) * Used when a file is uploaded to a printer, creating a job ready for analysis */ async createPendingJob(printerId, fileName, metadata, printerName) { const hasAnalysisData = metadata.gcodePrintTimeSeconds !== null || metadata.filamentUsedGrams !== null || metadata.totalFilamentUsedGrams !== null || metadata.layerHeight !== null || metadata.totalLayers !== null; const analysisState = hasAnalysisData ? "ANALYZED" : "NOT_ANALYZED"; const job = this.printJobRepository.create({ printerId, printerName: printerName || null, fileName, status: "PENDING", analysisState, metadata, fileFormat: metadata.fileFormat, fileSize: metadata.fileSize, analyzedAt: hasAnalysisData ? /* @__PURE__ */ new Date() : null }); await this.printJobRepository.save(job); this.logger.log(`Created ${analysisState.toLowerCase()} print job ${job.id} for printer ${printerId}: ${fileName} (format: ${metadata.fileFormat})`); return job; } /** * Trigger file analysis for a job (emits event for async processing) * Used when a print starts from a file on the printer that we don't have locally */ async triggerFileAnalysis(jobId) { this.eventEmitter2.emit("printJob.needsFileDownload", { jobId }); this.logger.log(`Triggered file download and analysis for job ${jobId}`); } async markAsCompleted(jobId, reason) { const job = await this.getJobByIdOrFail(jobId); job.status = "COMPLETED"; updateStatisticsForCompletion(job); if (reason) job.statusReason = reason; else job.statusReason = "Manually marked as completed by user"; await this.printJobRepository.save(job); this.logger.log(`Job ${jobId} manually marked as COMPLETED`); return job; } async markAsFailed(jobId, reason) { const job = await this.getJobByIdOrFail(jobId); job.status = "FAILED"; updateStatisticsForFailure(job, reason); await this.printJobRepository.save(job); this.logger.log(`Job ${jobId} manually marked as FAILED: ${reason}`); return job; } async markAsCancelled(jobId, reason) { const job = await this.getJobByIdOrFail(jobId); job.status = "CANCELLED"; updateStatisticsForCancellation(job, reason); await this.printJobRepository.save(job); this.logger.log(`Job ${jobId} manually marked as CANCELLED`); return job; } async markAsUnknown(jobId, reason) { const job = await this.getJobByIdOrFail(jobId); job.status = "UNKNOWN"; job.statusReason = reason || "Manually marked as unknown by user (state uncertain)"; await this.printJobRepository.save(job); this.logger.log(`Job ${jobId} manually marked as UNKNOWN`); return job; } async countJobsReferencingFile(fileStorageId) { return await this.printJobRepository.count({ where: { fileStorageId } }); } async updateJob(job) { return await this.printJobRepository.save(job); } async deleteJob(job) { await this.printJobRepository.remove(job); } }; //#endregion export { PrintJobService }; //# sourceMappingURL=print-job.service.js.map