| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457 |
- import io
- import logging
- import zipfile
- from pathlib import Path
- from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
- from fastapi.responses import FileResponse, Response
- from sqlalchemy import func, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.config import settings
- from backend.app.core.database import get_db
- from backend.app.models.archive import PrintArchive
- from backend.app.models.filament import Filament
- from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
- from backend.app.services.archive import ArchiveService
- logger = logging.getLogger(__name__)
- 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
- # Sanity check: skip unreasonable values (e.g., manually changed status)
- # Valid range: 5% to 500% (print took 20x longer to 5x faster than estimated)
- if 5 <= accuracy <= 500:
- 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,
- "project_id": archive.project_id,
- "project_name": archive.project.name if archive.project else None,
- "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,
- "source_3mf_path": archive.source_3mf_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,
- "total_layers": archive.total_layers,
- "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,
- "quantity": archive.quantity,
- "energy_kwh": archive.energy_kwh,
- "energy_cost": archive.energy_cost,
- "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,
- project_id: int | None = None,
- limit: int = 50,
- offset: int = 0,
- db: AsyncSession = Depends(get_db),
- ):
- """List archived prints."""
- service = ArchiveService(db)
- archives = await service.list_archives(
- printer_id=printer_id,
- project_id=project_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("/search", response_model=list[ArchiveResponse])
- async def search_archives(
- q: str = Query(..., min_length=2, description="Search query"),
- printer_id: int | None = None,
- project_id: int | None = None,
- status: str | None = None,
- limit: int = 50,
- offset: int = 0,
- db: AsyncSession = Depends(get_db),
- ):
- """Full-text search across archives.
- Searches print_name, filename, tags, notes, designer, and filament_type fields.
- Supports partial matches with wildcards (e.g., 'vor*' matches 'voron').
- """
- from sqlalchemy import text
- from sqlalchemy.orm import selectinload
- # Prepare search query - add wildcard for partial matches
- search_term = q.strip()
- if not search_term.endswith("*"):
- search_term = f"{search_term}*"
- # Build the FTS query
- # Using MATCH for FTS5 full-text search
- fts_query = text("""
- SELECT rowid FROM archive_fts
- WHERE archive_fts MATCH :search_term
- ORDER BY rank
- LIMIT :limit OFFSET :offset
- """)
- try:
- result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
- matched_ids = [row[0] for row in result.fetchall()]
- except Exception as e:
- logger.warning(f"FTS search failed, falling back to LIKE search: {e}")
- # Fallback to LIKE search if FTS fails
- like_pattern = f"%{q}%"
- query = (
- select(PrintArchive)
- .options(selectinload(PrintArchive.project))
- .where(
- (PrintArchive.print_name.ilike(like_pattern))
- | (PrintArchive.filename.ilike(like_pattern))
- | (PrintArchive.tags.ilike(like_pattern))
- | (PrintArchive.notes.ilike(like_pattern))
- | (PrintArchive.designer.ilike(like_pattern))
- | (PrintArchive.filament_type.ilike(like_pattern))
- )
- .order_by(PrintArchive.created_at.desc())
- )
- if printer_id:
- query = query.where(PrintArchive.printer_id == printer_id)
- if project_id:
- query = query.where(PrintArchive.project_id == project_id)
- if status:
- query = query.where(PrintArchive.status == status)
- query = query.limit(limit).offset(offset)
- result = await db.execute(query)
- archives = result.scalars().all()
- return [archive_to_response(a) for a in archives]
- if not matched_ids:
- return []
- # Fetch full archive records for matched IDs
- query = select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(matched_ids))
- # Apply additional filters
- if printer_id:
- query = query.where(PrintArchive.printer_id == printer_id)
- if project_id:
- query = query.where(PrintArchive.project_id == project_id)
- if status:
- query = query.where(PrintArchive.status == status)
- result = await db.execute(query)
- archives_dict = {a.id: a for a in result.scalars().all()}
- # Preserve FTS ranking order and apply pagination
- ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]
- paginated = ordered_archives[offset : offset + limit]
- return [archive_to_response(a) for a in paginated]
- @router.post("/search/rebuild-index")
- async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
- """Rebuild the full-text search index from existing archives.
- Use this if search results seem incomplete or incorrect.
- """
- from sqlalchemy import text
- try:
- # Clear and rebuild the FTS index
- await db.execute(text("DELETE FROM archive_fts"))
- # Repopulate from print_archives
- await db.execute(
- text("""
- INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
- SELECT id, print_name, filename, tags, notes, designer, filament_type
- FROM print_archives
- """)
- )
- await db.commit()
- # Count entries
- result = await db.execute(text("SELECT COUNT(*) FROM archive_fts"))
- count = result.scalar() or 0
- return {"message": f"Search index rebuilt with {count} entries"}
- except Exception as e:
- logger.error(f"Failed to rebuild search index: {e}")
- raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
- @router.get("/analysis/failures")
- async def analyze_failures(
- days: int = 30,
- printer_id: int | None = None,
- project_id: int | None = None,
- db: AsyncSession = Depends(get_db),
- ):
- """Analyze failure patterns across prints.
- Returns failure statistics including:
- - Overall failure rate
- - Failures by reason, filament type, printer
- - Time of day distribution
- - Recent failures
- - Weekly trend
- """
- from backend.app.services.failure_analysis import FailureAnalysisService
- service = FailureAnalysisService(db)
- return await service.analyze_failures(
- days=days,
- printer_id=printer_id,
- project_id=project_id,
- )
- @router.get("/compare")
- async def compare_archives(
- archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
- db: AsyncSession = Depends(get_db),
- ):
- """Compare multiple archives side by side.
- Compares print settings, filament usage, and print times.
- Also analyzes correlation between settings and success/failure.
- Args:
- archive_ids: Comma-separated list of 2-5 archive IDs to compare
- """
- from backend.app.services.archive_comparison import ArchiveComparisonService
- # Parse and validate archive IDs
- try:
- ids = [int(id.strip()) for id in archive_ids.split(",")]
- except ValueError:
- raise HTTPException(400, "Invalid archive IDs format")
- if len(ids) < 2:
- raise HTTPException(400, "At least 2 archives required for comparison")
- if len(ids) > 5:
- raise HTTPException(400, "Maximum 5 archives can be compared at once")
- service = ArchiveComparisonService(db)
- try:
- return await service.compare_archives(ids)
- except ValueError as e:
- raise HTTPException(400, str(e))
- @router.get("/export")
- async def export_archives(
- format: str = Query("csv", description="Export format: csv or xlsx"),
- fields: str | None = Query(None, description="Comma-separated field names"),
- printer_id: int | None = None,
- project_id: int | None = None,
- status: str | None = None,
- date_from: str | None = Query(None, description="Start date (ISO format)"),
- date_to: str | None = Query(None, description="End date (ISO format)"),
- search: str | None = None,
- db: AsyncSession = Depends(get_db),
- ):
- """Export archives to CSV or Excel format.
- Returns a downloadable file with archive data.
- """
- from datetime import datetime
- from fastapi.responses import StreamingResponse
- from backend.app.services.export import ExportService
- if format not in ("csv", "xlsx"):
- raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
- # Parse fields
- field_list = None
- if fields:
- field_list = [f.strip() for f in fields.split(",")]
- # Parse dates
- date_from_dt = None
- date_to_dt = None
- if date_from:
- try:
- date_from_dt = datetime.fromisoformat(date_from)
- except ValueError:
- raise HTTPException(400, "Invalid date_from format")
- if date_to:
- try:
- date_to_dt = datetime.fromisoformat(date_to)
- except ValueError:
- raise HTTPException(400, "Invalid date_to format")
- service = ExportService(db)
- try:
- file_bytes, filename, content_type = await service.export_archives(
- format=format,
- fields=field_list,
- printer_id=printer_id,
- project_id=project_id,
- status=status,
- date_from=date_from_dt,
- date_to=date_to_dt,
- search=search,
- )
- except ImportError as e:
- raise HTTPException(500, str(e))
- return StreamingResponse(
- io.BytesIO(file_bytes),
- media_type=content_type,
- headers={"Content-Disposition": f'attachment; filename="{filename}"'},
- )
- @router.get("/stats/export")
- async def export_stats(
- format: str = Query("csv", description="Export format: csv or xlsx"),
- days: int = 30,
- printer_id: int | None = None,
- project_id: int | None = None,
- db: AsyncSession = Depends(get_db),
- ):
- """Export statistics summary to CSV or Excel format."""
- from fastapi.responses import StreamingResponse
- from backend.app.services.export import ExportService
- if format not in ("csv", "xlsx"):
- raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
- service = ExportService(db)
- try:
- file_bytes, filename, content_type = await service.export_stats(
- format=format,
- days=days,
- printer_id=printer_id,
- project_id=project_id,
- )
- except ImportError as e:
- raise HTTPException(500, str(e))
- return StreamingResponse(
- io.BytesIO(file_bytes),
- media_type=content_type,
- headers={"Content-Disposition": f'attachment; filename="{filename}"'},
- )
- @router.get("/stats", response_model=ArchiveStats)
- async def get_archive_stats(db: AsyncSession = Depends(get_db)):
- """Get statistics across all archives."""
- # Total counts
- total_result = await db.execute(select(func.count(PrintArchive.id)))
- total_prints = total_result.scalar() or 0
- successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
- successful_prints = successful_result.scalar() or 0
- failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
- failed_prints = failed_result.scalar() or 0
- # Totals
- time_result = await db.execute(select(func.sum(PrintArchive.print_time_seconds)))
- total_time = (time_result.scalar() or 0) / 3600 # Convert to hours
- filament_result = await db.execute(select(func.sum(PrintArchive.filament_used_grams)))
- total_filament = filament_result.scalar() or 0
- cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
- total_cost = cost_result.scalar() or 0
- # By filament type (split comma-separated values for multi-material prints)
- filament_type_result = await db.execute(
- select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None))
- )
- prints_by_filament: dict[str, int] = {}
- for (filament_types,) in filament_type_result.all():
- # Split by comma and count each type
- for ftype in filament_types.split(","):
- ftype = ftype.strip()
- if ftype:
- prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1
- # By printer
- printer_result = await db.execute(
- select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
- )
- 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)
- # Energy totals - check which mode to use
- from backend.app.api.routes.settings import get_setting
- energy_tracking_mode = await get_setting(db, "energy_tracking_mode") or "total"
- energy_cost_per_kwh_str = await get_setting(db, "energy_cost_per_kwh")
- energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15
- if energy_tracking_mode == "total":
- # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
- from backend.app.models.smart_plug import SmartPlug
- from backend.app.services.tasmota import tasmota_service
- plugs_result = await db.execute(select(SmartPlug))
- plugs = list(plugs_result.scalars().all())
- total_energy_kwh = 0.0
- for plug in plugs:
- energy = await tasmota_service.get_energy(plug)
- if energy and energy.get("total") is not None:
- total_energy_kwh += energy["total"]
- total_energy_kwh = round(total_energy_kwh, 3)
- total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
- else:
- # Print mode: sum up per-print energy from archives
- energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)))
- total_energy_kwh = energy_kwh_result.scalar() or 0
- energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)))
- total_energy_cost = energy_cost_result.scalar() or 0
- return ArchiveStats(
- total_prints=total_prints,
- successful_prints=successful_prints,
- failed_prints=failed_prints,
- total_print_time_hours=round(total_time, 1),
- total_filament_grams=round(total_filament, 1),
- 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,
- total_energy_kwh=round(total_energy_kwh, 3),
- total_energy_cost=round(total_energy_cost, 2),
- )
- @router.get("/{archive_id}", response_model=ArchiveResponse)
- async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Get a specific archive."""
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- # 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.get("/{archive_id}/similar")
- async def find_similar_archives(
- archive_id: int,
- limit: int = 10,
- db: AsyncSession = Depends(get_db),
- ):
- """Find archives with similar settings for comparison.
- Returns archives that match by:
- - Same print name (highest priority)
- - Same file content hash
- - Same filament type
- """
- from backend.app.services.archive_comparison import ArchiveComparisonService
- service = ArchiveComparisonService(db)
- try:
- return await service.find_similar_archives(archive_id, limit=limit)
- except ValueError as e:
- raise HTTPException(404, str(e))
- @router.patch("/{archive_id}", response_model=ArchiveResponse)
- async def update_archive(
- archive_id: int,
- update_data: ArchiveUpdate,
- db: AsyncSession = Depends(get_db),
- ):
- """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
- from sqlalchemy.orm import selectinload
- result = await db.execute(
- select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id)
- )
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- for field, value in update_data.model_dump(exclude_unset=True).items():
- setattr(archive, field, value)
- await db.commit()
- # Re-fetch with project relationship loaded after commit
- result = await db.execute(
- select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id)
- )
- archive = result.scalar_one_or_none()
- return archive_to_response(archive)
- @router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
- async def toggle_favorite(
- archive_id: int,
- db: AsyncSession = Depends(get_db),
- ):
- """Toggle favorite status for an archive."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- archive.is_favorite = not archive.is_favorite
- await db.commit()
- await db.refresh(archive)
- return archive
- @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
- async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Rescan the 3MF file and update metadata."""
- from backend.app.services.archive import ThreeMFParser
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "Archive file not found")
- # Parse the 3MF file
- parser = ThreeMFParser(file_path)
- metadata = parser.parse()
- # Update fields from metadata
- if metadata.get("filament_type"):
- archive.filament_type = metadata["filament_type"]
- if metadata.get("filament_color"):
- archive.filament_color = metadata["filament_color"]
- if metadata.get("print_time_seconds"):
- archive.print_time_seconds = metadata["print_time_seconds"]
- if metadata.get("filament_used_grams"):
- archive.filament_used_grams = metadata["filament_used_grams"]
- if metadata.get("layer_height"):
- archive.layer_height = metadata["layer_height"]
- if metadata.get("nozzle_diameter"):
- archive.nozzle_diameter = metadata["nozzle_diameter"]
- if metadata.get("bed_temperature"):
- archive.bed_temperature = metadata["bed_temperature"]
- if metadata.get("nozzle_temperature"):
- archive.nozzle_temperature = metadata["nozzle_temperature"]
- if metadata.get("makerworld_url"):
- archive.makerworld_url = metadata["makerworld_url"]
- if metadata.get("designer"):
- archive.designer = metadata["designer"]
- # Calculate cost based on filament usage and type
- if archive.filament_used_grams and archive.filament_type:
- primary_type = archive.filament_type.split(",")[0].strip()
- filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
- filament = filament_result.scalar_one_or_none()
- if filament:
- archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2)
- else:
- archive.cost = round((archive.filament_used_grams / 1000) * 25.0, 2)
- await db.commit()
- await db.refresh(archive)
- return archive
- @router.post("/recalculate-costs")
- async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
- """Recalculate costs for all archives based on filament usage and prices."""
- result = await db.execute(select(PrintArchive))
- archives = list(result.scalars().all())
- # Load all filaments for lookup
- filament_result = await db.execute(select(Filament))
- filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}
- default_cost_per_kg = 25.0
- updated = 0
- for archive in archives:
- if archive.filament_used_grams and archive.filament_type:
- primary_type = archive.filament_type.split(",")[0].strip()
- cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
- new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
- if archive.cost != new_cost:
- archive.cost = new_cost
- updated += 1
- await db.commit()
- return {"message": f"Recalculated costs for {updated} archives", "updated": updated}
- @router.post("/rescan-all")
- async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
- """Rescan all archives and update their metadata."""
- from backend.app.services.archive import ThreeMFParser
- result = await db.execute(select(PrintArchive))
- 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
- parser = ThreeMFParser(file_path)
- metadata = parser.parse()
- if metadata.get("filament_type"):
- archive.filament_type = metadata["filament_type"]
- if metadata.get("filament_color"):
- archive.filament_color = metadata["filament_color"]
- if metadata.get("print_time_seconds"):
- archive.print_time_seconds = metadata["print_time_seconds"]
- if metadata.get("filament_used_grams"):
- archive.filament_used_grams = metadata["filament_used_grams"]
- if metadata.get("layer_height"):
- archive.layer_height = metadata["layer_height"]
- if metadata.get("nozzle_diameter"):
- archive.nozzle_diameter = metadata["nozzle_diameter"]
- if metadata.get("makerworld_url"):
- archive.makerworld_url = metadata["makerworld_url"]
- if metadata.get("designer"):
- archive.designer = metadata["designer"]
- updated += 1
- except Exception as e:
- errors.append({"id": archive.id, "error": str(e)})
- await db.commit()
- 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."""
- service = ArchiveService(db)
- if not await service.delete_archive(archive_id):
- raise HTTPException(404, "Archive not found")
- return {"status": "deleted"}
- @router.get("/{archive_id}/download")
- async def download_archive(
- archive_id: int,
- inline: bool = False,
- db: AsyncSession = Depends(get_db),
- ):
- """Download the 3MF file."""
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "File not found")
- # Use inline disposition to let browser/OS handle file association
- content_disposition = "inline" if inline else "attachment"
- return FileResponse(
- path=file_path,
- filename=archive.filename,
- media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
- content_disposition_type=content_disposition,
- )
- @router.get("/{archive_id}/file/{filename}")
- async def download_archive_with_filename(
- archive_id: int,
- filename: str,
- db: AsyncSession = Depends(get_db),
- ):
- """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "File not found")
- return FileResponse(
- path=file_path,
- filename=archive.filename,
- media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
- )
- @router.get("/{archive_id}/thumbnail")
- async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Get the thumbnail image."""
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive or not archive.thumbnail_path:
- raise HTTPException(404, "Thumbnail not found")
- thumb_path = settings.base_dir / archive.thumbnail_path
- if not thumb_path.exists():
- raise HTTPException(404, "Thumbnail file not found")
- # Use file modification time as ETag to bust cache
- mtime = int(thumb_path.stat().st_mtime)
- return FileResponse(
- path=thumb_path,
- media_type="image/png",
- headers={
- "Cache-Control": "no-cache, must-revalidate",
- "ETag": f'"{mtime}"',
- },
- )
- @router.get("/{archive_id}/timelapse")
- async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Get the timelapse video."""
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive or not archive.timelapse_path:
- raise HTTPException(404, "Timelapse not found")
- timelapse_path = settings.base_dir / archive.timelapse_path
- if not timelapse_path.exists():
- raise HTTPException(404, "Timelapse file not found")
- # Use file modification time as ETag to bust cache after processing
- mtime = int(timelapse_path.stat().st_mtime)
- return FileResponse(
- path=timelapse_path,
- media_type="video/mp4",
- filename=f"{archive.print_name or 'timelapse'}.mp4",
- headers={
- "Cache-Control": "no-cache, must-revalidate",
- "ETag": f'"{mtime}"',
- },
- )
- @router.post("/{archive_id}/timelapse/scan")
- async def scan_timelapse(
- archive_id: int,
- db: AsyncSession = Depends(get_db),
- ):
- """Scan printer for timelapse matching this archive and attach it."""
- from backend.app.models.printer import Printer
- from backend.app.services.bambu_ftp import (
- download_file_bytes_async,
- get_ftp_retry_settings,
- list_files_async,
- with_ftp_retry,
- )
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- if archive.timelapse_path:
- return {"status": "exists", "message": "Timelapse already attached"}
- if not archive.printer_id:
- raise HTTPException(400, "Archive has no associated printer")
- # Get printer
- result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Get base name from archive filename (without .3mf extension)
- base_name = Path(archive.filename).stem
- # Scan timelapse directory on printer
- # Try both /timelapse and /timelapse/video (different printer models use different paths)
- files = []
- for timelapse_path in ["/timelapse", "/timelapse/video"]:
- try:
- files = await list_files_async(printer.ip_address, printer.access_code, timelapse_path)
- if files:
- break
- except Exception:
- continue
- if not files:
- raise HTTPException(500, "Failed to connect to printer or no timelapse directory found")
- # Look for matching timelapse
- matching_file = None
- mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
- # Strategy 1: Match by print name in filename
- for f in mp4_files:
- fname = f.get("name", "")
- if base_name.lower() in fname.lower():
- matching_file = f
- break
- # Strategy 2: Match by timestamp proximity
- # Bambu timelapse filename uses the print START time (when recording began)
- if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
- import re
- from datetime import datetime, timedelta
- # Prefer started_at since video filename is the print start time
- # Fall back to completed_at or created_at if started_at is not available
- archive_start = archive.started_at
- archive_end = archive.completed_at or archive.created_at
- best_match = None
- best_diff = timedelta(hours=24) # Max 24 hour difference
- for f in mp4_files:
- fname = f.get("name", "")
- # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
- match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname)
- if match:
- try:
- file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
- # Try multiple timezone offsets since printer timezone can vary
- # Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset)
- for hour_offset in [0, 8, -8, 7, -7, 1, -1]:
- adjusted_file_time = file_time - timedelta(hours=hour_offset)
- # Check against start time (video filename = print start)
- if archive_start:
- diff = abs(adjusted_file_time - archive_start)
- if diff < best_diff:
- best_diff = diff
- best_match = f
- logger.debug(
- f"Timelapse match candidate: {fname} with offset {hour_offset}h, "
- f"diff from start: {diff}"
- )
- # Also check against end time with a buffer
- # (video timestamp should be BEFORE completion time)
- if archive_end:
- # The video timestamp should be within the print duration before completion
- if adjusted_file_time < archive_end:
- diff = archive_end - adjusted_file_time
- # Reasonable print duration: up to 48 hours
- if diff < timedelta(hours=48) and diff < best_diff:
- best_diff = diff
- best_match = f
- logger.debug(
- f"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, "
- f"diff: {diff}"
- )
- except ValueError:
- continue
- # Accept match within 4 hours (more lenient for timezone issues)
- if best_match and best_diff < timedelta(hours=4):
- matching_file = best_match
- logger.info(f"Matched timelapse by timestamp: {best_match.get('name')} (diff: {best_diff})")
- # Strategy 3: Use file modification time from FTP listing
- # This handles cases where printer's filename timestamp is wrong but file mtime is correct
- if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
- from datetime import datetime, timedelta
- archive_start = archive.started_at
- archive_end = archive.completed_at or archive.created_at
- best_match = None
- best_diff = timedelta(hours=24)
- for f in mp4_files:
- mtime = f.get("mtime")
- if mtime:
- # Timelapse file should be modified during or shortly after the print
- # The mtime should be close to completion time (video finishes when print ends)
- if archive_end:
- diff = abs(mtime - archive_end)
- if diff < best_diff:
- best_diff = diff
- best_match = f
- logger.debug(
- f"Timelapse mtime match candidate: {f.get('name')}, mtime: {mtime}, diff from end: {diff}"
- )
- if best_match and best_diff < timedelta(hours=2):
- matching_file = best_match
- logger.info(f"Matched timelapse by file mtime: {best_match.get('name')} (diff: {best_diff})")
- # Strategy 4: If only one timelapse exists and archive was recently completed, use it
- # This handles cases where printer clock is wrong or timezone issues exist
- if not matching_file and len(mp4_files) == 1:
- from datetime import datetime, timedelta
- archive_completed = archive.completed_at or archive.created_at
- if archive_completed:
- time_since_completion = datetime.now() - archive_completed
- # If archive was completed within the last hour, assume the single timelapse is for it
- if time_since_completion < timedelta(hours=1):
- matching_file = mp4_files[0]
- logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
- # Note: We intentionally don't use a "most recent file" fallback because
- # we can't verify if timelapse was actually enabled for this print.
- # Instead, return the list of available files for manual selection.
- if not matching_file:
- # Return available files for manual selection
- available_files = [
- {
- "name": f.get("name"),
- "path": f.get("path"),
- "size": f.get("size"),
- "mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
- }
- for f in mp4_files
- ]
- # Sort by mtime descending (most recent first)
- available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
- return {
- "status": "not_found",
- "message": "No matching timelapse found - please select manually",
- "available_files": available_files,
- }
- # Download the timelapse - use the full path from the file listing
- remote_path = matching_file.get("path") or f"/timelapse/{matching_file['name']}"
- # Get FTP retry settings
- ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
- if ftp_retry_enabled:
- timelapse_data = await with_ftp_retry(
- download_file_bytes_async,
- printer.ip_address,
- printer.access_code,
- remote_path,
- max_retries=ftp_retry_count,
- retry_delay=ftp_retry_delay,
- operation_name=f"Download timelapse {matching_file['name']}",
- )
- else:
- timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
- if not timelapse_data:
- raise HTTPException(500, "Failed to download timelapse")
- # Attach timelapse to archive
- success = await service.attach_timelapse(archive_id, timelapse_data, matching_file["name"])
- if not success:
- raise HTTPException(500, "Failed to attach timelapse")
- return {
- "status": "attached",
- "message": f"Timelapse '{matching_file['name']}' attached successfully",
- "filename": matching_file["name"],
- }
- @router.post("/{archive_id}/timelapse/select")
- async def select_timelapse(
- archive_id: int,
- filename: str = Query(..., description="Timelapse filename to attach"),
- db: AsyncSession = Depends(get_db),
- ):
- """Manually select a timelapse from the printer to attach."""
- from backend.app.models.printer import Printer
- from backend.app.services.bambu_ftp import (
- download_file_bytes_async,
- get_ftp_retry_settings,
- list_files_async,
- with_ftp_retry,
- )
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not archive.printer_id:
- raise HTTPException(400, "Archive has no associated printer")
- result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Find the file on the printer
- files = []
- remote_path = None
- for timelapse_dir in ["/timelapse", "/timelapse/video"]:
- try:
- files = await list_files_async(printer.ip_address, printer.access_code, timelapse_dir)
- for f in files:
- if f.get("name") == filename:
- remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
- break
- if remote_path:
- break
- except Exception:
- continue
- if not remote_path:
- raise HTTPException(404, f"Timelapse '{filename}' not found on printer")
- # Download and attach
- ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
- if ftp_retry_enabled:
- timelapse_data = await with_ftp_retry(
- download_file_bytes_async,
- printer.ip_address,
- printer.access_code,
- remote_path,
- max_retries=ftp_retry_count,
- retry_delay=ftp_retry_delay,
- operation_name=f"Download timelapse {filename}",
- )
- else:
- timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
- if not timelapse_data:
- raise HTTPException(500, "Failed to download timelapse")
- success = await service.attach_timelapse(archive_id, timelapse_data, filename)
- if not success:
- raise HTTPException(500, "Failed to attach timelapse")
- return {
- "status": "attached",
- "message": f"Timelapse '{filename}' attached successfully",
- "filename": filename,
- }
- @router.post("/{archive_id}/timelapse/upload")
- async def upload_timelapse(
- archive_id: int,
- file: UploadFile = File(...),
- db: AsyncSession = Depends(get_db),
- ):
- """Manually upload a timelapse video to an archive."""
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
- raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)")
- content = await file.read()
- success = await service.attach_timelapse(archive_id, content, file.filename)
- if not success:
- raise HTTPException(500, "Failed to attach timelapse")
- return {"status": "attached", "filename": file.filename}
- @router.get("/{archive_id}/timelapse/info")
- async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Get timelapse video metadata for editor."""
- from backend.app.schemas.timelapse import TimelapseInfoResponse
- from backend.app.services.timelapse_processor import TimelapseProcessor
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive or not archive.timelapse_path:
- raise HTTPException(404, "Timelapse not found")
- timelapse_path = settings.base_dir / archive.timelapse_path
- if not timelapse_path.exists():
- raise HTTPException(404, "Timelapse file not found")
- try:
- processor = TimelapseProcessor(timelapse_path)
- info = await processor.get_info()
- return TimelapseInfoResponse(**info)
- except Exception as e:
- logger.error(f"Failed to get timelapse info: {e}")
- raise HTTPException(500, f"Failed to get video info: {str(e)}")
- @router.get("/{archive_id}/timelapse/thumbnails")
- async def get_timelapse_thumbnails(
- archive_id: int,
- count: int = Query(10, ge=1, le=30),
- width: int = Query(160, ge=80, le=320),
- db: AsyncSession = Depends(get_db),
- ):
- """Generate timeline thumbnail frames for visual scrubbing."""
- import base64
- from backend.app.schemas.timelapse import ThumbnailResponse
- from backend.app.services.timelapse_processor import TimelapseProcessor
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive or not archive.timelapse_path:
- raise HTTPException(404, "Timelapse not found")
- timelapse_path = settings.base_dir / archive.timelapse_path
- if not timelapse_path.exists():
- raise HTTPException(404, "Timelapse file not found")
- try:
- processor = TimelapseProcessor(timelapse_path)
- thumbnails = await processor.generate_thumbnails(count, width)
- return ThumbnailResponse(
- thumbnails=[base64.b64encode(data).decode() for _, data in thumbnails],
- timestamps=[ts for ts, _ in thumbnails],
- )
- except Exception as e:
- logger.error(f"Failed to generate thumbnails: {e}")
- raise HTTPException(500, f"Failed to generate thumbnails: {str(e)}")
- @router.post("/{archive_id}/timelapse/process")
- async def process_timelapse(
- archive_id: int,
- trim_start: float = Form(0),
- trim_end: float = Form(None),
- speed: float = Form(1.0),
- save_mode: str = Form("new"),
- output_filename: str = Form(None),
- audio: UploadFile = File(None),
- db: AsyncSession = Depends(get_db),
- ):
- """Process timelapse with trim, speed, and optional audio overlay."""
- import shutil
- import tempfile
- from backend.app.schemas.timelapse import ProcessResponse
- from backend.app.services.timelapse_processor import TimelapseProcessor
- # Validate speed
- if not 0.25 <= speed <= 4.0:
- raise HTTPException(400, "Speed must be between 0.25 and 4.0")
- if save_mode not in ("replace", "new"):
- raise HTTPException(400, "save_mode must be 'replace' or 'new'")
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive or not archive.timelapse_path:
- raise HTTPException(404, "Timelapse not found")
- timelapse_path = settings.base_dir / archive.timelapse_path
- if not timelapse_path.exists():
- raise HTTPException(404, "Timelapse file not found")
- archive_dir = timelapse_path.parent
- # Handle audio file
- audio_temp_path = None
- if audio and audio.filename:
- # Validate audio file extension
- if not audio.filename.lower().endswith((".mp3", ".wav", ".m4a", ".aac", ".ogg")):
- raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
- audio_content = await audio.read()
- suffix = Path(audio.filename).suffix
- audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
- audio_temp_path.write_bytes(audio_content)
- try:
- processor = TimelapseProcessor(timelapse_path)
- # Determine output path
- if save_mode == "replace":
- # Process to temp file first, then replace
- temp_output = Path(tempfile.gettempdir()) / f"processed_{archive_id}.mp4"
- output_path = temp_output
- else:
- # Save as new file alongside original
- filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
- # Sanitize filename
- filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
- if not filename.endswith(".mp4"):
- filename += ".mp4"
- output_path = archive_dir / filename
- success = await processor.process(
- output_path=output_path,
- trim_start=trim_start,
- trim_end=trim_end,
- speed=speed,
- audio_path=audio_temp_path,
- )
- if not success:
- raise HTTPException(500, "Video processing failed")
- # Handle save mode
- if save_mode == "replace":
- # Replace original file
- shutil.move(str(output_path), str(timelapse_path))
- final_path = archive.timelapse_path
- message = "Timelapse replaced successfully"
- else:
- final_path = str(output_path.relative_to(settings.base_dir))
- message = f"Saved as {output_path.name}"
- return ProcessResponse(
- status="completed",
- output_path=final_path,
- message=message,
- )
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Timelapse processing failed: {e}")
- raise HTTPException(500, f"Processing failed: {str(e)}")
- finally:
- # Cleanup temp audio file
- if audio_temp_path and audio_temp_path.exists():
- audio_temp_path.unlink()
- # ============================================
- # Photo Endpoints
- # ============================================
- @router.post("/{archive_id}/photos")
- async def upload_photo(
- archive_id: int,
- file: UploadFile = File(...),
- db: AsyncSession = Depends(get_db),
- ):
- """Upload a photo of the printed result."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
- raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
- # Get archive directory
- file_path = settings.base_dir / archive.file_path
- archive_dir = file_path.parent
- photos_dir = archive_dir / "photos"
- photos_dir.mkdir(exist_ok=True)
- # Generate unique filename
- import uuid
- ext = Path(file.filename).suffix.lower()
- photo_filename = f"{uuid.uuid4().hex[:8]}{ext}"
- photo_path = photos_dir / photo_filename
- # Save file
- content = await file.read()
- photo_path.write_bytes(content)
- # Update archive photos list (create new list to trigger SQLAlchemy change detection)
- photos = list(archive.photos or [])
- photos.append(photo_filename)
- archive.photos = photos
- await db.commit()
- await db.refresh(archive)
- return {"status": "uploaded", "filename": photo_filename, "photos": archive.photos}
- @router.get("/{archive_id}/photos/{filename}")
- async def get_photo(
- archive_id: int,
- filename: str,
- db: AsyncSession = Depends(get_db),
- ):
- """Get a specific photo."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- photo_path = file_path.parent / "photos" / filename
- if not photo_path.exists():
- raise HTTPException(404, "Photo not found")
- # Determine media type
- ext = Path(filename).suffix.lower()
- media_types = {
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".png": "image/png",
- ".webp": "image/webp",
- }
- media_type = media_types.get(ext, "image/jpeg")
- return FileResponse(path=photo_path, media_type=media_type)
- @router.delete("/{archive_id}/photos/{filename}")
- async def delete_photo(
- archive_id: int,
- filename: str,
- db: AsyncSession = Depends(get_db),
- ):
- """Delete a photo."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not archive.photos or filename not in archive.photos:
- raise HTTPException(404, "Photo not found")
- # Delete file
- file_path = settings.base_dir / archive.file_path
- photo_path = file_path.parent / "photos" / filename
- if photo_path.exists():
- photo_path.unlink()
- # Update archive photos list
- photos = [p for p in archive.photos if p != filename]
- archive.photos = photos if photos else None
- await db.commit()
- return {"status": "deleted", "photos": archive.photos}
- # ============================================
- # QR Code Endpoint
- # ============================================
- @router.get("/{archive_id}/qrcode")
- async def get_qrcode(
- archive_id: int,
- request: Request,
- size: int = 200,
- db: AsyncSession = Depends(get_db),
- ):
- """Generate a QR code that links to this archive."""
- try:
- import qrcode
- from PIL import Image as PILImage
- except ImportError:
- raise HTTPException(500, "QR code generation not available - qrcode package not installed")
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- # Build URL to archive download
- base_url = str(request.base_url).rstrip("/")
- archive_url = f"{base_url}/api/v1/archives/{archive_id}/download"
- # Generate QR code
- qr = qrcode.QRCode(
- version=1,
- error_correction=qrcode.constants.ERROR_CORRECT_M,
- box_size=10,
- border=2,
- )
- qr.add_data(archive_url)
- qr.make(fit=True)
- img = qr.make_image(fill_color="black", back_color="white")
- # Convert to PIL Image for resizing
- pil_img = img.get_image()
- # Resize if needed
- if size != 200:
- pil_img = pil_img.resize((size, size), PILImage.Resampling.LANCZOS)
- # Convert to bytes
- buffer = io.BytesIO()
- pil_img.save(buffer, format="PNG")
- buffer.seek(0)
- return Response(
- content=buffer.getvalue(),
- media_type="image/png",
- headers={"Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'},
- )
- @router.get("/{archive_id}/capabilities")
- async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Check what viewing capabilities are available for this 3MF file."""
- import json
- import xml.etree.ElementTree as ET
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "File not found")
- has_model = False
- has_gcode = False
- has_source = False
- build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size
- filament_colors: list[str] = []
- # Check if source 3MF exists - this is where actual mesh data typically lives
- source_path = None
- if archive.source_3mf_path:
- source_path = settings.base_dir / archive.source_3mf_path
- if source_path.exists():
- has_source = True
- # Helper function to check for mesh data and extract colors from a 3MF file
- def extract_3mf_info(zf_path: Path) -> tuple[bool, list[str], dict]:
- """Extract mesh presence, colors, and build volume from a 3MF file."""
- found_mesh = False
- colors: list[str] = []
- volume = {"x": 256, "y": 256, "z": 256}
- try:
- with zipfile.ZipFile(zf_path, "r") as zf:
- names = zf.namelist()
- # Check for 3D model - look for actual mesh data
- for name in names:
- if name.endswith(".model"):
- try:
- content = zf.read(name).decode("utf-8")
- if "<vertex" in content or "<mesh" in content:
- found_mesh = True
- break
- except Exception:
- pass
- # Extract filament colors from project_settings.config
- if "Metadata/project_settings.config" in names:
- try:
- config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
- config_data = json.loads(config_content)
- # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
- printable_area = config_data.get("printable_area", [])
- if printable_area and len(printable_area) >= 3:
- max_x = 0
- max_y = 0
- for coord in printable_area:
- if "x" in coord:
- parts = coord.split("x")
- if len(parts) == 2:
- try:
- x, y = int(parts[0]), int(parts[1])
- max_x = max(max_x, x)
- max_y = max(max_y, y)
- except ValueError:
- pass
- if max_x > 0 and max_y > 0:
- volume["x"] = max_x
- volume["y"] = max_y
- # Parse printable_height
- printable_height = config_data.get("printable_height")
- if printable_height:
- try:
- volume["z"] = int(printable_height)
- except (ValueError, TypeError):
- pass
- # Extract filament colors
- raw_colors = config_data.get("filament_colour", [])
- if raw_colors:
- for color in raw_colors:
- if color and isinstance(color, str):
- colors.append(color)
- except Exception:
- pass
- except zipfile.BadZipFile:
- pass
- return found_mesh, colors, volume
- # First check source 3MF for mesh data and colors (preferred for 3D model viewing)
- if has_source and source_path:
- source_has_mesh, source_colors, source_volume = extract_3mf_info(source_path)
- if source_has_mesh:
- has_model = True
- if source_colors:
- filament_colors = source_colors
- if source_volume["x"] != 256 or source_volume["y"] != 256 or source_volume["z"] != 256:
- build_volume = source_volume
- try:
- with zipfile.ZipFile(file_path, "r") as zf:
- names = zf.namelist()
- # Check for G-code in the sliced file
- has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names)
- # Check for 3D model in sliced file (fallback if no source)
- if not has_model:
- for name in names:
- if name.endswith(".model"):
- try:
- content = zf.read(name).decode("utf-8")
- if "<vertex" in content or "<mesh" in content:
- has_model = True
- break
- except Exception:
- pass
- # Extract filament colors from slice_info.config (for gcode preview)
- # These are the actual filaments used in the print, indexed by tool/extruder
- slice_colors: list[str] = []
- if "Metadata/slice_info.config" in names:
- try:
- slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
- root = ET.fromstring(slice_content)
- filaments = root.findall(".//filament")
- filament_map: dict[int, str] = {}
- for f in filaments:
- fid = f.get("id")
- fcolor = f.get("color")
- used_g = f.get("used_g", "0")
- try:
- used_amount = float(used_g)
- except (ValueError, TypeError):
- used_amount = 0
- if fid is not None and fcolor:
- try:
- tool_id = int(fid) - 1
- if tool_id >= 0 and used_amount > 0:
- filament_map[tool_id] = fcolor
- except ValueError:
- pass
- if filament_map:
- max_tool = max(filament_map.keys())
- for i in range(max_tool + 1):
- slice_colors.append(filament_map.get(i, "#00AE42"))
- except Exception:
- pass
- # Use slice_info colors if we don't have colors from source yet
- if not filament_colors and slice_colors:
- filament_colors = slice_colors
- # Extract build volume from sliced file if not already set from source
- if build_volume["x"] == 256 and build_volume["y"] == 256:
- if "Metadata/project_settings.config" in names:
- try:
- config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
- config_data = json.loads(config_content)
- printable_area = config_data.get("printable_area", [])
- if printable_area and len(printable_area) >= 3:
- max_x = 0
- max_y = 0
- for coord in printable_area:
- if "x" in coord:
- parts = coord.split("x")
- if len(parts) == 2:
- try:
- x, y = int(parts[0]), int(parts[1])
- max_x = max(max_x, x)
- max_y = max(max_y, y)
- except ValueError:
- pass
- if max_x > 0 and max_y > 0:
- build_volume["x"] = max_x
- build_volume["y"] = max_y
- printable_height = config_data.get("printable_height")
- if printable_height:
- try:
- build_volume["z"] = int(printable_height)
- except (ValueError, TypeError):
- pass
- # Fallback colors from project_settings if still empty
- if not filament_colors:
- raw_colors = config_data.get("filament_colour", [])
- if raw_colors:
- for color in raw_colors:
- if color and isinstance(color, str):
- filament_colors.append(color)
- except Exception:
- pass
- except zipfile.BadZipFile:
- raise HTTPException(400, "Invalid 3MF file")
- return {
- "has_model": has_model,
- "has_gcode": has_gcode,
- "has_source": has_source,
- "build_volume": build_volume,
- "filament_colors": filament_colors,
- }
- @router.get("/{archive_id}/gcode")
- async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Extract and return G-code from the 3MF file."""
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "File not found")
- try:
- with zipfile.ZipFile(file_path, "r") as zf:
- # Bambu 3MF files store G-code in Metadata/plate_X.gcode
- gcode_files = [n for n in zf.namelist() if n.startswith("Metadata/") and n.endswith(".gcode")]
- if not gcode_files:
- raise HTTPException(
- 404,
- "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio.",
- )
- # Get the first plate's G-code (usually plate_1.gcode)
- gcode_content = zf.read(gcode_files[0]).decode("utf-8")
- return Response(content=gcode_content, media_type="text/plain")
- except zipfile.BadZipFile:
- raise HTTPException(400, "Invalid 3MF file")
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(500, f"Error extracting G-code: {str(e)}")
- @router.get("/{archive_id}/plate-preview")
- async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Get the plate preview image from the 3MF file.
- Returns the slicer-generated plate thumbnail which shows the model
- with correct colors and positioning.
- """
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "File not found")
- try:
- with zipfile.ZipFile(file_path, "r") as zf:
- names = zf.namelist()
- # Try to find plate preview images in order of preference
- # First look for the specific plate being printed (check slice_info for plate index)
- plate_num = 1
- if "Metadata/slice_info.config" in names:
- try:
- import xml.etree.ElementTree as ET
- slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
- root = ET.fromstring(slice_content)
- plate_elem = root.find(".//plate/metadata[@key='index']")
- if plate_elem is not None:
- plate_num = int(plate_elem.get("value", "1"))
- except Exception:
- pass
- # Try plate-specific image first, then fall back to plate_1
- preview_paths = [
- f"Metadata/plate_{plate_num}.png",
- "Metadata/plate_1.png",
- "Metadata/thumbnail.png",
- ]
- for preview_path in preview_paths:
- if preview_path in names:
- image_data = zf.read(preview_path)
- return Response(content=image_data, media_type="image/png")
- # If no plate image, try any PNG in Metadata
- for name in names:
- if name.startswith("Metadata/plate_") and name.endswith(".png") and "_small" not in name:
- image_data = zf.read(name)
- return Response(content=image_data, media_type="image/png")
- raise HTTPException(404, "No plate preview found in 3MF file")
- except zipfile.BadZipFile:
- raise HTTPException(400, "Invalid 3MF file")
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(500, f"Error extracting plate preview: {str(e)}")
- @router.post("/upload")
- async def upload_archive(
- file: UploadFile = File(...),
- printer_id: int | None = None,
- db: AsyncSession = Depends(get_db),
- ):
- """Manually upload a 3MF file to archive."""
- if not file.filename or not file.filename.endswith(".3mf"):
- raise HTTPException(400, "File must be a .3mf file")
- # Save uploaded file temporarily
- temp_path = settings.archive_dir / "temp" / file.filename
- temp_path.parent.mkdir(parents=True, exist_ok=True)
- try:
- content = await file.read()
- temp_path.write_bytes(content)
- service = ArchiveService(db)
- archive = await service.archive_print(
- printer_id=printer_id,
- source_file=temp_path,
- )
- if not archive:
- raise HTTPException(400, "Failed to archive file")
- return ArchiveResponse.model_validate(archive)
- finally:
- if temp_path.exists():
- temp_path.unlink()
- @router.post("/upload-bulk")
- async def upload_archives_bulk(
- files: list[UploadFile] = File(...),
- printer_id: int | None = None,
- db: AsyncSession = Depends(get_db),
- ):
- """Bulk upload multiple 3MF files to archive."""
- results = []
- errors = []
- for file in files:
- if not file.filename or not file.filename.endswith(".3mf"):
- errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"})
- continue
- temp_path = settings.archive_dir / "temp" / file.filename
- temp_path.parent.mkdir(parents=True, exist_ok=True)
- try:
- content = await file.read()
- temp_path.write_bytes(content)
- service = ArchiveService(db)
- archive = await service.archive_print(
- printer_id=printer_id,
- source_file=temp_path,
- )
- if archive:
- results.append(
- {
- "filename": file.filename,
- "id": archive.id,
- "status": "success",
- }
- )
- else:
- errors.append({"filename": file.filename, "error": "Failed to process"})
- except Exception as e:
- errors.append({"filename": file.filename, "error": str(e)})
- finally:
- if temp_path.exists():
- temp_path.unlink()
- return {
- "uploaded": len(results),
- "failed": len(errors),
- "results": results,
- "errors": errors,
- }
- @router.get("/{archive_id}/filament-requirements")
- async def get_filament_requirements(
- archive_id: int,
- db: AsyncSession = Depends(get_db),
- ):
- """Get filament requirements from the archived 3MF file.
- Returns the filaments used in this print with their slot IDs, types, colors,
- and usage amounts. This can be compared with current AMS state before reprinting.
- """
- import xml.etree.ElementTree as ET
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "Archive file not found")
- filaments = []
- try:
- with zipfile.ZipFile(file_path, "r") as zf:
- # Parse slice_info.config for filament requirements
- if "Metadata/slice_info.config" in zf.namelist():
- content = zf.read("Metadata/slice_info.config").decode()
- root = ET.fromstring(content)
- # Extract filament elements
- # Format: <filament id="1" type="PLA" color="#FFFFFF" used_g="100" used_m="10" />
- for filament_elem in root.findall(".//filament"):
- filament_id = filament_elem.get("id")
- filament_type = filament_elem.get("type", "")
- filament_color = filament_elem.get("color", "")
- used_g = filament_elem.get("used_g", "0")
- used_m = filament_elem.get("used_m", "0")
- # Only include filaments that are actually used
- try:
- used_grams = float(used_g)
- except (ValueError, TypeError):
- used_grams = 0
- if used_grams > 0 and filament_id:
- filaments.append(
- {
- "slot_id": int(filament_id),
- "type": filament_type,
- "color": filament_color,
- "used_grams": round(used_grams, 1),
- "used_meters": float(used_m) if used_m else 0,
- }
- )
- # Sort by slot ID
- filaments.sort(key=lambda x: x["slot_id"])
- except Exception as e:
- logger.warning(f"Failed to parse filament requirements from archive {archive_id}: {e}")
- return {
- "archive_id": archive_id,
- "filename": archive.filename,
- "filaments": filaments,
- }
- @router.post("/{archive_id}/reprint")
- async def reprint_archive(
- archive_id: int,
- printer_id: int,
- body: ReprintRequest | None = None,
- db: AsyncSession = Depends(get_db),
- ):
- """Send an archived 3MF file to a printer and start printing."""
- from backend.app.main import register_expected_print
- from backend.app.models.printer import Printer
- from backend.app.services.bambu_ftp import (
- get_ftp_retry_settings,
- upload_file_async,
- with_ftp_retry,
- )
- from backend.app.services.printer_manager import printer_manager
- # Use defaults if no body provided
- if body is None:
- body = ReprintRequest()
- # Get archive
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- # Get printer
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Check printer is connected
- if not printer_manager.is_connected(printer_id):
- raise HTTPException(400, "Printer is not connected")
- # Get the sliced 3MF file path
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "Archive file not found")
- # Upload file to printer via FTP
- from backend.app.services.bambu_ftp import delete_file_async
- # Use a clean filename to avoid issues with double extensions like .gcode.3mf
- # The printer might reject filenames with unusual extensions
- base_name = archive.filename
- if base_name.endswith(".gcode.3mf"):
- base_name = base_name[:-10] # Remove .gcode.3mf
- elif base_name.endswith(".3mf"):
- base_name = base_name[:-4] # Remove .3mf
- remote_filename = f"{base_name}.3mf"
- remote_path = f"/{remote_filename}"
- # Delete existing file if present (avoids 553 error)
- await delete_file_async(
- printer.ip_address,
- printer.access_code,
- remote_path,
- )
- # Get FTP retry settings
- ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
- if ftp_retry_enabled:
- uploaded = await with_ftp_retry(
- upload_file_async,
- printer.ip_address,
- printer.access_code,
- file_path,
- remote_path,
- max_retries=ftp_retry_count,
- retry_delay=ftp_retry_delay,
- operation_name=f"Upload for reprint to {printer.name}",
- )
- else:
- uploaded = await upload_file_async(
- printer.ip_address,
- printer.access_code,
- file_path,
- remote_path,
- )
- if not uploaded:
- raise HTTPException(500, "Failed to upload file to printer")
- # Register this as an expected print so we don't create a duplicate archive
- register_expected_print(printer_id, remote_filename, archive_id)
- # Detect plate ID from 3MF file
- plate_id = 1
- try:
- with zipfile.ZipFile(file_path, "r") as zf:
- for name in zf.namelist():
- if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
- # Extract plate number from "Metadata/plate_X.gcode"
- plate_str = name[15:-6] # Remove "Metadata/plate_" and ".gcode"
- plate_id = int(plate_str)
- break
- except Exception:
- pass # Default to plate 1 if detection fails
- logger.info(
- f"Reprint archive {archive_id}: plate_id={plate_id}, "
- f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}, "
- f"flow_cali={body.flow_cali}, vibration_cali={body.vibration_cali}, "
- f"layer_inspect={body.layer_inspect}, timelapse={body.timelapse}"
- )
- # Start the print with options
- started = printer_manager.start_print(
- printer_id,
- remote_filename,
- plate_id,
- ams_mapping=body.ams_mapping,
- timelapse=body.timelapse,
- bed_levelling=body.bed_levelling,
- flow_cali=body.flow_cali,
- vibration_cali=body.vibration_cali,
- layer_inspect=body.layer_inspect,
- use_ams=body.use_ams,
- )
- if not started:
- raise HTTPException(500, "Failed to start print")
- return {
- "status": "printing",
- "printer_id": printer_id,
- "archive_id": archive_id,
- "filename": archive.filename,
- }
- # =============================================================================
- # Project Page API
- # =============================================================================
- @router.get("/{archive_id}/project-page")
- async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
- """Get the project page data from the 3MF file."""
- from backend.app.schemas.archive import ProjectPageResponse
- from backend.app.services.archive import ProjectPageParser
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "Archive file not found")
- parser = ProjectPageParser(file_path)
- data = parser.parse(archive_id)
- return ProjectPageResponse(**data)
- @router.patch("/{archive_id}/project-page")
- async def update_project_page(
- archive_id: int,
- update_data: dict,
- db: AsyncSession = Depends(get_db),
- ):
- """Update project page metadata in the 3MF file."""
- from backend.app.services.archive import ProjectPageParser
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "Archive file not found")
- parser = ProjectPageParser(file_path)
- success = parser.update_metadata(update_data)
- if not success:
- raise HTTPException(500, "Failed to update project page")
- # Return updated data
- data = parser.parse(archive_id)
- return data
- @router.get("/{archive_id}/project-image/{image_path:path}")
- async def get_project_image(
- archive_id: int,
- image_path: str,
- db: AsyncSession = Depends(get_db),
- ):
- """Get an image from the 3MF project page."""
- from backend.app.services.archive import ProjectPageParser
- service = ArchiveService(db)
- archive = await service.get_archive(archive_id)
- if not archive:
- raise HTTPException(404, "Archive not found")
- file_path = settings.base_dir / archive.file_path
- if not file_path.exists():
- raise HTTPException(404, "Archive file not found")
- parser = ProjectPageParser(file_path)
- result = parser.get_image(image_path)
- if not result:
- raise HTTPException(404, "Image not found in 3MF file")
- image_data, content_type = result
- return Response(
- content=image_data,
- media_type=content_type,
- headers={"Cache-Control": "max-age=3600"},
- )
- # =============================================================================
- # Source 3MF API (Original Project Files)
- # =============================================================================
- @router.post("/{archive_id}/source")
- async def upload_source_3mf(
- archive_id: int,
- file: UploadFile = File(...),
- db: AsyncSession = Depends(get_db),
- ):
- """Upload the original source 3MF project file for an archive."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not file.filename or not file.filename.endswith(".3mf"):
- raise HTTPException(400, "File must be a .3mf file")
- # Get archive directory and create source subdirectory
- file_path = settings.base_dir / archive.file_path
- archive_dir = file_path.parent
- source_dir = archive_dir / "source"
- source_dir.mkdir(exist_ok=True)
- # Delete old source file if exists
- if archive.source_3mf_path:
- old_source_path = settings.base_dir / archive.source_3mf_path
- if old_source_path.exists():
- old_source_path.unlink()
- # Save the source 3MF file - preserve original filename
- source_filename = file.filename
- source_path = source_dir / source_filename
- content = await file.read()
- source_path.write_bytes(content)
- # Update archive with source path (relative to base_dir)
- archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
- await db.commit()
- await db.refresh(archive)
- return {
- "status": "uploaded",
- "source_3mf_path": archive.source_3mf_path,
- "filename": source_filename,
- }
- @router.get("/{archive_id}/source")
- async def download_source_3mf(
- archive_id: int,
- db: AsyncSession = Depends(get_db),
- ):
- """Download the source 3MF project file."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not archive.source_3mf_path:
- raise HTTPException(404, "No source 3MF attached to this archive")
- source_path = settings.base_dir / archive.source_3mf_path
- if not source_path.exists():
- raise HTTPException(404, "Source 3MF file not found on disk")
- # Use the actual filename from the path
- filename = source_path.name
- return FileResponse(
- path=source_path,
- filename=filename,
- media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
- )
- @router.get("/{archive_id}/source/{filename}")
- async def download_source_3mf_for_slicer(
- archive_id: int,
- filename: str,
- db: AsyncSession = Depends(get_db),
- ):
- """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not archive.source_3mf_path:
- raise HTTPException(404, "No source 3MF attached to this archive")
- source_path = settings.base_dir / archive.source_3mf_path
- if not source_path.exists():
- raise HTTPException(404, "Source 3MF file not found on disk")
- return FileResponse(
- path=source_path,
- filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
- media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
- )
- @router.post("/upload-source")
- async def upload_source_3mf_by_name(
- file: UploadFile = File(...),
- print_name: str = Query(None, description="Match archive by print name"),
- db: AsyncSession = Depends(get_db),
- ):
- """Upload source 3MF and match to archive by print name.
- This endpoint is designed for slicer post-processing scripts.
- It finds the most recent archive matching the print name and attaches the source.
- """
- if not file.filename or not file.filename.endswith(".3mf"):
- raise HTTPException(400, "File must be a .3mf file")
- # Derive print name from filename if not provided
- if not print_name:
- # Remove .3mf extension and common suffixes
- print_name = file.filename.rsplit(".3mf", 1)[0]
- # Remove _source suffix if present
- if print_name.endswith("_source"):
- print_name = print_name[:-7]
- # Find matching archive - try exact match first, then fuzzy
- result = await db.execute(
- select(PrintArchive)
- .where(PrintArchive.print_name == print_name)
- .order_by(PrintArchive.created_at.desc())
- .limit(1)
- )
- archive = result.scalar_one_or_none()
- if not archive:
- # Try matching filename without .gcode.3mf
- result = await db.execute(
- select(PrintArchive)
- .where(PrintArchive.filename.like(f"{print_name}%"))
- .order_by(PrintArchive.created_at.desc())
- .limit(1)
- )
- archive = result.scalar_one_or_none()
- if not archive:
- # Try case-insensitive partial match on print_name
- result = await db.execute(
- select(PrintArchive)
- .where(PrintArchive.print_name.ilike(f"%{print_name}%"))
- .order_by(PrintArchive.created_at.desc())
- .limit(1)
- )
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, f"No archive found matching '{print_name}'")
- # Get archive directory and create source subdirectory
- file_path = settings.base_dir / archive.file_path
- archive_dir = file_path.parent
- source_dir = archive_dir / "source"
- source_dir.mkdir(exist_ok=True)
- # Delete old source file if exists
- if archive.source_3mf_path:
- old_source_path = settings.base_dir / archive.source_3mf_path
- if old_source_path.exists():
- old_source_path.unlink()
- # Save the source 3MF file - preserve original filename
- source_filename = file.filename
- source_path = source_dir / source_filename
- content = await file.read()
- source_path.write_bytes(content)
- # Update archive with source path
- archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
- await db.commit()
- await db.refresh(archive)
- return {
- "status": "uploaded",
- "archive_id": archive.id,
- "archive_name": archive.print_name or archive.filename,
- "source_3mf_path": archive.source_3mf_path,
- "filename": source_filename,
- }
- @router.delete("/{archive_id}/source")
- async def delete_source_3mf(
- archive_id: int,
- db: AsyncSession = Depends(get_db),
- ):
- """Delete the source 3MF project file from an archive."""
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(404, "Archive not found")
- if not archive.source_3mf_path:
- raise HTTPException(404, "No source 3MF attached to this archive")
- # Delete the file
- source_path = settings.base_dir / archive.source_3mf_path
- if source_path.exists():
- source_path.unlink()
- # Clear the path in database
- archive.source_3mf_path = None
- await db.commit()
- return {"status": "deleted"}
|