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