diff --git a/backend/API/Jobs/MoonrakerPrinterSyncJob.cs b/backend/API/Jobs/MoonrakerPrinterSyncJob.cs
new file mode 100644
index 0000000..f227c36
--- /dev/null
+++ b/backend/API/Jobs/MoonrakerPrinterSyncJob.cs
@@ -0,0 +1,80 @@
+using Extrudex.Domain.Interfaces;
+using Extrudex.Infrastructure.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Extrudex.API.Jobs;
+
+///
+/// Background service that periodically syncs Moonraker printer status
+/// and print job history into the Extrudex database. Runs as a hosted
+/// service and polls all active Moonraker printers on a configurable
+/// interval to update printer state and map completed print jobs
+/// to PrintJob and FilamentUsage entities.
+///
+/// Configuration is bound from the "MoonrakerPrinterSync" section in
+/// appsettings.json. Set Enabled=false to disable without removing
+/// the service registration.
+///
+public class MoonrakerPrinterSyncJob : BackgroundService
+{
+ private readonly IMoonrakerPrinterSyncService _syncService;
+ private readonly MoonrakerPrinterSyncOptions _options;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new MoonrakerPrinterSyncJob.
+ ///
+ /// The service that performs the actual sync logic.
+ /// Configuration options for polling interval and timeouts.
+ /// Logger for diagnostic output.
+ public MoonrakerPrinterSyncJob(
+ IMoonrakerPrinterSyncService syncService,
+ IOptions options,
+ ILogger logger)
+ {
+ _syncService = syncService;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (!_options.Enabled)
+ {
+ _logger.LogInformation("Moonraker printer sync job is disabled via configuration — exiting");
+ return;
+ }
+
+ _logger.LogInformation(
+ "Moonraker printer sync job starting — polling every {Interval}",
+ _options.PollingInterval);
+
+ // Delay briefly on startup to allow the web host to fully initialize
+ await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var syncedCount = await _syncService.SyncAllAsync(stoppingToken);
+
+ _logger.LogInformation(
+ "Moonraker printer sync completed — {SyncedCount} printer(s) synced. Next sync in {Interval}",
+ syncedCount, _options.PollingInterval);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex,
+ "Error during Moonraker printer sync cycle — will retry in {Interval}",
+ _options.PollingInterval);
+ }
+
+ await Task.Delay(_options.PollingInterval, stoppingToken);
+ }
+
+ _logger.LogInformation("Moonraker printer sync job shutting down");
+ }
+}
\ No newline at end of file
diff --git a/backend/Domain/Interfaces/IMoonrakerPrinterSyncService.cs b/backend/Domain/Interfaces/IMoonrakerPrinterSyncService.cs
new file mode 100644
index 0000000..bb89ff8
--- /dev/null
+++ b/backend/Domain/Interfaces/IMoonrakerPrinterSyncService.cs
@@ -0,0 +1,20 @@
+using Extrudex.Domain.DTOs.Moonraker;
+
+namespace Extrudex.Domain.Interfaces;
+
+///
+/// Service interface for syncing Moonraker printer data into the Extrudex database.
+/// Handles periodic polling of printer status and mapping print job history
+/// to PrintJob and FilamentUsage entities.
+///
+public interface IMoonrakerPrinterSyncService
+{
+ ///
+ /// Performs a single sync cycle: queries all active Moonraker printers,
+ /// fetches their current status and print job history, and persists
+ /// updates to the database.
+ ///
+ /// Cancellation token for graceful shutdown.
+ /// The number of printers successfully synced.
+ Task SyncAllAsync(CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/backend/Infrastructure/Configuration/MoonrakerPrinterSyncOptions.cs b/backend/Infrastructure/Configuration/MoonrakerPrinterSyncOptions.cs
new file mode 100644
index 0000000..442048b
--- /dev/null
+++ b/backend/Infrastructure/Configuration/MoonrakerPrinterSyncOptions.cs
@@ -0,0 +1,41 @@
+namespace Extrudex.Infrastructure.Configuration;
+
+///
+/// Configuration options for the MoonrakerPrinterSync background service.
+/// Bound from appsettings.json under the "MoonrakerPrinterSync" section.
+/// Controls polling interval, timeouts, and feature toggles for the
+/// printer status and print job mapping service.
+///
+public class MoonrakerPrinterSyncOptions
+{
+ ///
+ /// The section name in appsettings.json where these options are bound.
+ ///
+ public const string SectionName = "MoonrakerPrinterSync";
+
+ ///
+ /// How often the background service polls Moonraker printers for status
+ /// and print job data. Default: 1 minute.
+ ///
+ public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(1);
+
+ ///
+ /// Timeout for individual HTTP requests to a Moonraker printer.
+ /// Default: 15 seconds.
+ ///
+ public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
+
+ ///
+ /// Whether the Moonraker printer sync service is enabled.
+ /// Set to false to disable without removing the service registration.
+ /// Default: true.
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// Maximum number of print history items to fetch per printer per sync cycle.
+ /// Controls the batch size when syncing print jobs from Moonraker.
+ /// Default: 25.
+ ///
+ public int HistoryBatchSize { get; set; } = 25;
+}
\ No newline at end of file
diff --git a/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs b/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs
new file mode 100644
index 0000000..48c20a2
--- /dev/null
+++ b/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs
@@ -0,0 +1,320 @@
+using Extrudex.Domain.DTOs.Moonraker;
+using Extrudex.Domain.Entities;
+using Extrudex.Domain.Enums;
+using Extrudex.Domain.Interfaces;
+using Extrudex.Infrastructure.Configuration;
+using Extrudex.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Extrudex.Infrastructure.Services;
+
+///
+/// Service that syncs Moonraker printer status and print job history into the
+/// Extrudex database. Queries all active Moonraker printers, fetches their
+/// current operational state, and maps completed print jobs to PrintJob and
+/// FilamentUsage entities with derived gram calculations.
+///
+public class MoonrakerPrinterSyncService : IMoonrakerPrinterSyncService
+{
+ private readonly ExtrudexDbContext _dbContext;
+ private readonly IMoonrakerClient _moonrakerClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new MoonrakerPrinterSyncService.
+ ///
+ /// The EF Core database context for persisting updates.
+ /// The Moonraker HTTP client for fetching printer data.
+ /// Logger for diagnostic output.
+ public MoonrakerPrinterSyncService(
+ ExtrudexDbContext dbContext,
+ IMoonrakerClient moonrakerClient,
+ ILogger logger)
+ {
+ _dbContext = dbContext;
+ _moonrakerClient = moonrakerClient;
+ _logger = logger;
+ }
+
+ ///
+ public async Task SyncAllAsync(CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation("Starting Moonraker printer sync cycle");
+
+ var printers = await _dbContext.Printers
+ .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker)
+ .Include(p => p.AmsUnits)
+ .ThenInclude(u => u.Slots)
+ .ThenInclude(s => s.Spool)
+ .ThenInclude(s => s.MaterialBase)
+ .Include(p => p.PrintJobs)
+ .ToListAsync(cancellationToken);
+
+ if (printers.Count == 0)
+ {
+ _logger.LogInformation("No active Moonraker printers found — skipping sync");
+ return 0;
+ }
+
+ _logger.LogInformation("Found {PrinterCount} active Moonraker printer(s) to sync", printers.Count);
+
+ var syncedCount = 0;
+
+ foreach (var printer in printers)
+ {
+ try
+ {
+ await SyncPrinterAsync(printer, cancellationToken);
+ syncedCount++;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex,
+ "Error syncing printer {PrinterName} ({Host}:{Port})",
+ printer.Name, printer.HostnameOrIp, printer.Port);
+
+ // Mark printer as offline if we can't reach it
+ printer.Status = PrinterStatus.Offline;
+ }
+ }
+
+ await _dbContext.SaveChangesAsync(cancellationToken);
+
+ _logger.LogInformation(
+ "Moonraker printer sync cycle complete — {SyncedCount}/{TotalCount} printers synced",
+ syncedCount, printers.Count);
+
+ return syncedCount;
+ }
+
+ ///
+ /// Syncs a single Moonraker printer: updates its status, fetches print history,
+ /// and maps new print jobs to database entities.
+ ///
+ private async Task SyncPrinterAsync(Printer printer, CancellationToken cancellationToken)
+ {
+ // Step 1: Fetch printer status
+ var printerInfo = await _moonrakerClient.GetPrinterInfoAsync(
+ printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken);
+
+ var printStats = await _moonrakerClient.GetPrintStatsAsync(
+ printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken);
+
+ // Step 2: Update printer status
+ UpdatePrinterStatus(printer, printerInfo, printStats);
+ printer.LastSeenAt = DateTime.UtcNow;
+
+ _logger.LogDebug(
+ "Printer {PrinterName} status updated to {Status}",
+ printer.Name, printer.Status);
+
+ // Step 3: Fetch and map print job history
+ var history = await _moonrakerClient.GetPrintHistoryAsync(
+ printer.HostnameOrIp, printer.Port, printer.ApiKey,
+ limit: 25,
+ cancellationToken);
+
+ if (history.Items.Count == 0)
+ {
+ _logger.LogDebug("No print history returned for printer {PrinterName}", printer.Name);
+ return;
+ }
+
+ var newJobsCount = await MapPrintJobsAsync(printer, history.Items, cancellationToken);
+
+ if (newJobsCount > 0)
+ {
+ _logger.LogInformation(
+ "Mapped {NewJobsCount} new print job(s) from printer {PrinterName}",
+ newJobsCount, printer.Name);
+ }
+ }
+
+ ///
+ /// Updates the printer's operational status based on Moonraker telemetry.
+ /// Maps Klipper/Moonraker state strings to the PrinterStatus enum.
+ ///
+ private void UpdatePrinterStatus(
+ Printer printer,
+ MoonrakerPrinterInfo? printerInfo,
+ MoonrakerPrintStats? printStats)
+ {
+ // Prefer print_stats state — it's the most authoritative
+ if (printStats != null)
+ {
+ printer.Status = printStats.State.ToLowerInvariant() switch
+ {
+ "printing" => PrinterStatus.Printing,
+ "paused" => PrinterStatus.Paused,
+ "complete" => PrinterStatus.Idle,
+ "standby" => PrinterStatus.Idle,
+ "cancelled" => PrinterStatus.Idle,
+ "error" => PrinterStatus.Error,
+ _ => PrinterStatus.Idle
+ };
+ return;
+ }
+
+ // Fall back to printer_info state
+ if (printerInfo != null)
+ {
+ printer.Status = printerInfo.State.ToLowerInvariant() switch
+ {
+ "ready" => PrinterStatus.Idle,
+ "startup" => PrinterStatus.Idle,
+ "shutdown" => PrinterStatus.Offline,
+ "error" => PrinterStatus.Error,
+ "cancelled" => PrinterStatus.Idle,
+ _ => printer.Status // Preserve existing status if unknown
+ };
+ }
+ }
+
+ ///
+ /// Maps Moonraker print job history items to Extrudex PrintJob and FilamentUsage entities.
+ /// Only creates records for jobs not already tracked (by Moonraker JobId stored in GcodeFilePath).
+ ///
+ private async Task MapPrintJobsAsync(
+ Printer printer,
+ List historyItems,
+ CancellationToken cancellationToken)
+ {
+ // Build a set of already-tracked Moonraker JobIds for this printer
+ // We store the Moonraker JobId in the GcodeFilePath field with a "moonraker:" prefix
+ var trackedJobIds = await _dbContext.PrintJobs
+ .Where(pj => pj.PrinterId == printer.Id && pj.GcodeFilePath != null && pj.GcodeFilePath.StartsWith("moonraker:"))
+ .Select(pj => pj.GcodeFilePath!)
+ .ToListAsync(cancellationToken);
+
+ var trackedIdSet = new HashSet(trackedJobIds);
+ var newJobsCount = 0;
+
+ // Find the default spool for this printer (first active spool in AMS, or first active spool overall)
+ var defaultSpool = FindDefaultSpool(printer);
+
+ foreach (var moonrakerJob in historyItems)
+ {
+ var jobIdKey = $"moonraker:{moonrakerJob.JobId}";
+
+ if (trackedIdSet.Contains(jobIdKey))
+ {
+ continue; // Already tracked — skip
+ }
+
+ // Only map completed, cancelled, or errored jobs (not in_progress)
+ // In-progress jobs will be captured on the next cycle once they finish
+ if (moonrakerJob.Status == "in_progress")
+ {
+ continue;
+ }
+
+ // Map Moonraker job status to JobStatus enum
+ var jobStatus = moonrakerJob.Status.ToLowerInvariant() switch
+ {
+ "completed" => JobStatus.Completed,
+ "cancelled" => JobStatus.Cancelled,
+ "error" => JobStatus.Failed,
+ _ => JobStatus.Completed
+ };
+
+ // Calculate derived grams if we have a spool and filament data
+ decimal gramsDerived = 0m;
+ decimal filamentDiameterMm = 1.75m;
+ decimal materialDensity = 1.24m; // PLA default
+
+ if (defaultSpool != null)
+ {
+ filamentDiameterMm = defaultSpool.FilamentDiameterMm;
+ materialDensity = defaultSpool.MaterialBase.DensityGperCm3;
+ gramsDerived = CalculateGrams(moonrakerJob.FilamentUsedMm, filamentDiameterMm, materialDensity);
+ }
+ else if (moonrakerJob.FilamentUsedMm > 0)
+ {
+ gramsDerived = CalculateGrams(moonrakerJob.FilamentUsedMm, 1.75m, 1.24m);
+ _logger.LogWarning(
+ "No default spool found for printer {PrinterName} — using PLA defaults for grams derivation on job {JobId}",
+ printer.Name, moonrakerJob.JobId);
+ }
+
+ var printJob = new PrintJob
+ {
+ PrinterId = printer.Id,
+ SpoolId = defaultSpool?.Id ?? Guid.Empty,
+ PrintName = moonrakerJob.Filename,
+ GcodeFilePath = jobIdKey,
+ MmExtruded = moonrakerJob.FilamentUsedMm,
+ GramsDerived = gramsDerived,
+ StartedAt = moonrakerJob.StartTime,
+ CompletedAt = moonrakerJob.EndTime,
+ Status = jobStatus,
+ DataSource = DataSource.Moonraker,
+ FilamentDiameterAtPrintMm = filamentDiameterMm,
+ MaterialDensityAtPrint = materialDensity,
+ Notes = $"Auto-imported from Moonraker (JobId: {moonrakerJob.JobId})"
+ };
+
+ _dbContext.PrintJobs.Add(printJob);
+
+ // Create a FilamentUsage record if filament was consumed
+ if (moonrakerJob.FilamentUsedMm > 0 && defaultSpool != null)
+ {
+ var usage = new FilamentUsage
+ {
+ PrintJob = printJob,
+ SpoolId = defaultSpool.Id,
+ PrinterId = printer.Id,
+ GramsUsed = gramsDerived,
+ MmExtruded = moonrakerJob.FilamentUsedMm,
+ RecordedAt = DateTime.UtcNow,
+ Notes = $"Auto-imported from Moonraker history (JobId: {moonrakerJob.JobId})"
+ };
+
+ _dbContext.FilamentUsages.Add(usage);
+ }
+
+ newJobsCount++;
+ trackedIdSet.Add(jobIdKey); // Prevent duplicates within this batch
+ }
+
+ return newJobsCount;
+ }
+
+ ///
+ /// Finds the default spool for a printer. Returns the first spool loaded
+ /// in an AMS slot, or null if no spool is available.
+ ///
+ private static Spool? FindDefaultSpool(Printer printer)
+ {
+ // Prefer the first active spool in an AMS slot
+ foreach (var amsUnit in printer.AmsUnits)
+ {
+ foreach (var slot in amsUnit.Slots)
+ {
+ if (slot.Spool != null && slot.Spool.IsActive && !slot.Spool.IsArchived)
+ {
+ return slot.Spool;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Calculates derived grams from millimeters extruded using the standard formula:
+ /// grams = mm_extruded × cross_section_area × material_density
+ /// where cross_section_area = π × (diameter / 2)²
+ ///
+ private static decimal CalculateGrams(decimal mmExtruded, decimal diameterMm, decimal densityGperCm3)
+ {
+ if (mmExtruded <= 0) return 0m;
+
+ var radiusCm = (double)diameterMm / 2.0 / 10.0; // mm to cm
+ var crossSectionAreaCm2 = Math.PI * radiusCm * radiusCm;
+ var mmToCm = (double)mmExtruded / 10.0;
+
+ var grams = mmToCm * crossSectionAreaCm2 * (double)densityGperCm3;
+ return (decimal)grams;
+ }
+}
\ No newline at end of file
diff --git a/backend/Program.cs b/backend/Program.cs
index c2bdcc9..c3ac2a1 100644
--- a/backend/Program.cs
+++ b/backend/Program.cs
@@ -92,6 +92,12 @@ builder.Services.AddHttpClient(client =>
builder.Services.AddScoped();
builder.Services.AddHostedService();
+// ── Moonraker Printer Sync (Background Service) ──────────
+builder.Services.Configure(
+ builder.Configuration.GetSection(MoonrakerPrinterSyncOptions.SectionName));
+builder.Services.AddScoped();
+builder.Services.AddHostedService();
+
// ── Health Checks ───────────────────────────────────────────
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString);
diff --git a/backend/appsettings.json b/backend/appsettings.json
index e5c747f..d35bd81 100644
--- a/backend/appsettings.json
+++ b/backend/appsettings.json
@@ -14,5 +14,11 @@
"PollingInterval": "00:05:00",
"RequestTimeout": "00:00:30",
"Enabled": true
+ },
+ "MoonrakerPrinterSync": {
+ "PollingInterval": "00:01:00",
+ "RequestTimeout": "00:00:15",
+ "Enabled": true,
+ "HistoryBatchSize": 25
}
}
\ No newline at end of file