From b407e1b6d8a66b3a9195f256f898aceb47cc9531 Mon Sep 17 00:00:00 2001 From: Martin Ziegler Date: Fri, 28 Nov 2025 13:40:06 +0100 Subject: [PATCH] Added calculation for print time accuracy; Added duplicate detection and filter --- README.md | 12 + backend/app/api/routes/archives.py | 178 +++- backend/app/core/database.py | 9 + backend/app/models/archive.py | 1 + backend/app/schemas/archive.py | 20 +- backend/app/services/archive.py | 101 ++- frontend/src/api/client.ts | 20 + frontend/src/pages/ArchivesPage.tsx | 39 +- frontend/src/pages/StatsPage.tsx | 91 +++ static/assets/index-BVoz8SLN.css | 1 - .../{index-Qa2rW77Z.js => index-DZsA8zAw.js} | 770 +++++++++--------- static/assets/index-DwtaaDcN.css | 1 + static/index.html | 4 +- 13 files changed, 852 insertions(+), 395 deletions(-) delete mode 100644 static/assets/index-BVoz8SLN.css rename static/assets/{index-Qa2rW77Z.js => index-DZsA8zAw.js} (70%) create mode 100644 static/assets/index-DwtaaDcN.css diff --git a/README.md b/README.md index c5d4c3198..78764cf4b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,16 @@ - Filament usage trends - Print activity calendar - Cost tracking + - Time accuracy tracking +- **Print Time Accuracy** - Compare estimated vs actual print times + - Color-coded badges (green=accurate, blue=faster, orange=slower) + - Per-printer accuracy statistics + - Dashboard widget showing average accuracy +- **Duplicate Detection** - Automatically detect when models have been printed before + - SHA256 content hash for exact matching + - Purple badge indicator on archive cards + - "Duplicates" collection filter to find all duplicates + - View duplicate history when viewing archive details - **Filament Cost Tracking** - Track costs per print with customizable filament database - **Photo Attachments** - Attach photos to archived prints for documentation - **Failure Analysis** - Document failed prints with notes and photos @@ -488,6 +498,8 @@ sudo journalctl -u bambusy -f - [ ] Mobile-responsive improvements - [ ] Printer groups/organization - [x] Smart plug integration (Tasmota) +- [x] Print time accuracy tracking +- [x] Duplicate detection ## License diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index bba693a3d..6a3a714d5 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -17,6 +17,78 @@ from backend.app.services.archive import ArchiveService router = APIRouter(prefix="/archives", tags=["archives"]) +def compute_time_accuracy(archive: PrintArchive) -> dict: + """Compute actual print time and accuracy for an archive. + + Returns dict with actual_time_seconds and time_accuracy. + time_accuracy = (estimated / actual) * 100 + - 100% = perfect estimate + - >100% = print was faster than estimated + - <100% = print took longer than estimated + """ + result = {"actual_time_seconds": None, "time_accuracy": None} + + if archive.started_at and archive.completed_at and archive.status == "completed": + actual_seconds = int((archive.completed_at - archive.started_at).total_seconds()) + if actual_seconds > 0: + result["actual_time_seconds"] = actual_seconds + + if archive.print_time_seconds and archive.print_time_seconds > 0: + # Calculate accuracy as percentage + accuracy = (archive.print_time_seconds / actual_seconds) * 100 + result["time_accuracy"] = round(accuracy, 1) + + return result + + +def archive_to_response( + archive: PrintArchive, + duplicates: list[dict] | None = None, + duplicate_count: int = 0, +) -> dict: + """Convert archive model to response dict with computed fields.""" + data = { + "id": archive.id, + "printer_id": archive.printer_id, + "filename": archive.filename, + "file_path": archive.file_path, + "file_size": archive.file_size, + "content_hash": archive.content_hash, + "thumbnail_path": archive.thumbnail_path, + "timelapse_path": archive.timelapse_path, + "duplicates": duplicates, + "duplicate_count": duplicate_count if duplicates is None else len(duplicates), + "print_name": archive.print_name, + "print_time_seconds": archive.print_time_seconds, + "filament_used_grams": archive.filament_used_grams, + "filament_type": archive.filament_type, + "filament_color": archive.filament_color, + "layer_height": archive.layer_height, + "nozzle_diameter": archive.nozzle_diameter, + "bed_temperature": archive.bed_temperature, + "nozzle_temperature": archive.nozzle_temperature, + "status": archive.status, + "started_at": archive.started_at, + "completed_at": archive.completed_at, + "extra_data": archive.extra_data, + "makerworld_url": archive.makerworld_url, + "designer": archive.designer, + "is_favorite": archive.is_favorite, + "tags": archive.tags, + "notes": archive.notes, + "cost": archive.cost, + "photos": archive.photos, + "failure_reason": archive.failure_reason, + "created_at": archive.created_at, + } + + # Add computed time accuracy fields + accuracy_data = compute_time_accuracy(archive) + data.update(accuracy_data) + + return data + + @router.get("/", response_model=list[ArchiveResponse]) async def list_archives( printer_id: int | None = None, @@ -26,12 +98,22 @@ async def list_archives( ): """List archived prints.""" service = ArchiveService(db) - return await service.list_archives( + archives = await service.list_archives( printer_id=printer_id, limit=limit, offset=offset, ) + # Get set of hashes that have duplicates (efficient single query) + duplicate_hashes = await service.get_duplicate_hashes() + + # Mark archives that have duplicates + result = [] + for a in archives: + has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False + result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0)) + return result + @router.get("/stats", response_model=ArchiveStats) async def get_archive_stats(db: AsyncSession = Depends(get_db)): @@ -86,6 +168,42 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): ) prints_by_printer = {str(k): v for k, v in printer_result.all()} + # Time accuracy statistics + # Get all completed archives with both estimated and actual times + accuracy_result = await db.execute( + select(PrintArchive) + .where(PrintArchive.status == "completed") + .where(PrintArchive.print_time_seconds.isnot(None)) + .where(PrintArchive.started_at.isnot(None)) + .where(PrintArchive.completed_at.isnot(None)) + ) + archives_with_times = list(accuracy_result.scalars().all()) + + average_accuracy = None + accuracy_by_printer: dict[str, float] = {} + + if archives_with_times: + accuracies = [] + printer_accuracies: dict[str, list[float]] = {} + + for archive in archives_with_times: + acc_data = compute_time_accuracy(archive) + if acc_data["time_accuracy"] is not None: + accuracies.append(acc_data["time_accuracy"]) + + # Group by printer + printer_key = str(archive.printer_id) if archive.printer_id else "unknown" + if printer_key not in printer_accuracies: + printer_accuracies[printer_key] = [] + printer_accuracies[printer_key].append(acc_data["time_accuracy"]) + + if accuracies: + average_accuracy = round(sum(accuracies) / len(accuracies), 1) + + # Calculate per-printer averages + for printer_key, accs in printer_accuracies.items(): + accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1) + return ArchiveStats( total_prints=total_prints, successful_prints=successful_prints, @@ -95,6 +213,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): total_cost=round(total_cost, 2), prints_by_filament_type=prints_by_filament, prints_by_printer=prints_by_printer, + average_time_accuracy=average_accuracy, + time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None, ) @@ -105,7 +225,16 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): archive = await service.get_archive(archive_id) if not archive: raise HTTPException(404, "Archive not found") - return archive + + # Find duplicates + makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None + duplicates = await service.find_duplicates( + archive_id=archive.id, + content_hash=archive.content_hash, + print_name=archive.print_name, + makerworld_model_id=makerworld_id, + ) + return archive_to_response(archive, duplicates) @router.patch("/{archive_id}", response_model=ArchiveResponse) @@ -242,6 +371,51 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)): return {"updated": updated, "errors": errors} +@router.get("/{archive_id}/duplicates") +async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get duplicates for a specific archive.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None + duplicates = await service.find_duplicates( + archive_id=archive.id, + content_hash=archive.content_hash, + print_name=archive.print_name, + makerworld_model_id=makerworld_id, + ) + return {"duplicates": duplicates, "count": len(duplicates)} + + +@router.post("/backfill-hashes") +async def backfill_content_hashes(db: AsyncSession = Depends(get_db)): + """Compute and store content hashes for all archives missing them.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.content_hash.is_(None)) + ) + archives = list(result.scalars().all()) + + updated = 0 + errors = [] + + for archive in archives: + try: + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + errors.append({"id": archive.id, "error": "File not found"}) + continue + + archive.content_hash = ArchiveService.compute_file_hash(file_path) + updated += 1 + except Exception as e: + errors.append({"id": archive.id, "error": str(e)}) + + await db.commit() + return {"updated": updated, "errors": errors} + + @router.delete("/{archive_id}") async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)): """Delete an archive.""" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index ef056b9a6..80fe47a32 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -55,3 +55,12 @@ async def run_migrations(conn): except Exception: # Column already exists pass + + # Migration: Add content_hash column to print_archives for duplicate detection + try: + await conn.execute(text( + "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)" + )) + except Exception: + # Column already exists + pass diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index 7c0719386..3e601ba9f 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -15,6 +15,7 @@ class PrintArchive(Base): filename: Mapped[str] = mapped_column(String(255)) file_path: Mapped[str] = mapped_column(String(500)) file_size: Mapped[int] = mapped_column(Integer) + content_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 hash for duplicate detection thumbnail_path: Mapped[str | None] = mapped_column(String(500)) timelapse_path: Mapped[str | None] = mapped_column(String(500)) diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 1d4df01e7..70f29ca24 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -15,17 +15,32 @@ class ArchiveUpdate(ArchiveBase): printer_id: int | None = None +class ArchiveDuplicate(BaseModel): + """Reference to a duplicate archive.""" + id: int + print_name: str | None + created_at: datetime + match_type: str # "exact" (hash match) or "similar" (name match) + + class ArchiveResponse(BaseModel): id: int printer_id: int | None filename: str file_path: str file_size: int + content_hash: str | None thumbnail_path: str | None timelapse_path: str | None + # Duplicate detection + duplicates: list[ArchiveDuplicate] | None = None + duplicate_count: int = 0 # Quick count for list views + print_name: str | None - print_time_seconds: int | None + print_time_seconds: int | None # Estimated time from slicer + actual_time_seconds: int | None = None # Computed from started_at/completed_at + time_accuracy: float | None = None # Percentage: 100 = perfect, >100 = faster than estimated filament_used_grams: float | None filament_type: str | None filament_color: str | None @@ -65,6 +80,9 @@ class ArchiveStats(BaseModel): total_cost: float prints_by_filament_type: dict prints_by_printer: dict + # Time accuracy stats + average_time_accuracy: float | None = None # Average across all prints with data + time_accuracy_by_printer: dict | None = None # Per-printer accuracy class ProjectPageImage(BaseModel): diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 7972276b1..ae15dbc67 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -1,3 +1,4 @@ +import hashlib import json import zipfile import shutil @@ -6,7 +7,7 @@ from pathlib import Path from xml.etree import ElementTree as ET from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, and_, or_ from backend.app.core.config import settings from backend.app.models.archive import PrintArchive @@ -470,6 +471,100 @@ class ArchiveService: def __init__(self, db: AsyncSession): self.db = db + @staticmethod + def compute_file_hash(file_path: Path) -> str: + """Compute SHA256 hash of a file for duplicate detection.""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + # Read in chunks to handle large files + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + + async def get_duplicate_hashes(self) -> set[str]: + """Get all content hashes that appear more than once. + + Returns a set of hashes that have duplicates. + """ + from sqlalchemy import func + + result = await self.db.execute( + select(PrintArchive.content_hash) + .where(PrintArchive.content_hash.isnot(None)) + .group_by(PrintArchive.content_hash) + .having(func.count(PrintArchive.id) > 1) + ) + return {row[0] for row in result.all()} + + async def find_duplicates( + self, + archive_id: int, + content_hash: str | None = None, + print_name: str | None = None, + makerworld_model_id: str | None = None, + ) -> list[dict]: + """Find duplicate archives based on hash or name matching. + + Returns list of dicts with id, print_name, created_at, match_type. + """ + duplicates = [] + + # First, find exact matches by content hash + if content_hash: + result = await self.db.execute( + select(PrintArchive) + .where( + and_( + PrintArchive.content_hash == content_hash, + PrintArchive.id != archive_id, + ) + ) + .order_by(PrintArchive.created_at.desc()) + .limit(10) + ) + for archive in result.scalars().all(): + duplicates.append({ + "id": archive.id, + "print_name": archive.print_name, + "created_at": archive.created_at, + "match_type": "exact", + }) + + # Then, find similar matches by print name or MakerWorld ID + if print_name or makerworld_model_id: + conditions = [PrintArchive.id != archive_id] + + name_conditions = [] + if print_name: + # Match if print names are similar (ignoring case) + name_conditions.append(PrintArchive.print_name.ilike(print_name)) + if makerworld_model_id: + # Match by MakerWorld model ID stored in extra_data + name_conditions.append( + PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id + ) + + if name_conditions: + conditions.append(or_(*name_conditions)) + + result = await self.db.execute( + select(PrintArchive) + .where(and_(*conditions)) + .order_by(PrintArchive.created_at.desc()) + .limit(10) + ) + for archive in result.scalars().all(): + # Don't add if already in duplicates (exact match) + if not any(d["id"] == archive.id for d in duplicates): + duplicates.append({ + "id": archive.id, + "print_name": archive.print_name, + "created_at": archive.created_at, + "match_type": "similar", + }) + + return duplicates + async def archive_print( self, printer_id: int | None, @@ -498,6 +593,9 @@ class ArchiveService: dest_file = archive_dir / source_file.name shutil.copy2(source_file, dest_file) + # Compute content hash for duplicate detection + content_hash = self.compute_file_hash(dest_file) + # Parse 3MF metadata parser = ThreeMFParser(dest_file) metadata = parser.parse() @@ -526,6 +624,7 @@ class ArchiveService: filename=source_file.name, file_path=str(dest_file.relative_to(settings.base_dir)), file_size=dest_file.stat().st_size, + content_hash=content_hash, thumbnail_path=thumbnail_path, print_name=metadata.get("print_name") or source_file.stem, print_time_seconds=metadata.get("print_time_seconds"), diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c3eeabbd2..2017b66c5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -66,16 +66,28 @@ export interface PrinterCreate { } // Archive types +export interface ArchiveDuplicate { + id: number; + print_name: string | null; + created_at: string; + match_type: 'exact' | 'similar'; // 'exact' = hash match, 'similar' = name match +} + export interface Archive { id: number; printer_id: number | null; filename: string; file_path: string; file_size: number; + content_hash: string | null; thumbnail_path: string | null; timelapse_path: string | null; + duplicates: ArchiveDuplicate[] | null; + duplicate_count: number; print_name: string | null; print_time_seconds: number | null; + actual_time_seconds: number | null; // Computed from started_at/completed_at + time_accuracy: number | null; // Percentage: 100 = perfect, >100 = faster than estimated filament_used_grams: number | null; filament_type: string | null; filament_color: string | null; @@ -107,6 +119,8 @@ export interface ArchiveStats { total_cost: number; prints_by_filament_type: Record; prints_by_printer: Record; + average_time_accuracy: number | null; + time_accuracy_by_printer: Record | null; } export interface BulkUploadResult { @@ -296,6 +310,12 @@ export const api = { deleteArchive: (id: number) => request(`/archives/${id}`, { method: 'DELETE' }), getArchiveStats: () => request('/archives/stats'), + getArchiveDuplicates: (id: number) => + request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`), + backfillContentHashes: () => + request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', { + method: 'POST', + }), getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`, getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`, getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`, diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index cf88b4e44..f04599dd0 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -293,6 +293,16 @@ function ArchiveCard({ failed )} + {/* Duplicate badge */} + {archive.duplicate_count > 0 && ( +
+ + duplicate +
+ )} {/* Timelapse badge */} {archive.timelapse_path && (