archives.py 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228
  1. from pathlib import Path
  2. import zipfile
  3. import io
  4. import logging
  5. from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
  6. logger = logging.getLogger(__name__)
  7. from fastapi.responses import FileResponse, Response
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy import select, func
  10. from backend.app.core.config import settings
  11. from backend.app.core.database import get_db
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.filament import Filament
  14. from backend.app.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats
  15. from backend.app.services.archive import ArchiveService
  16. router = APIRouter(prefix="/archives", tags=["archives"])
  17. def compute_time_accuracy(archive: PrintArchive) -> dict:
  18. """Compute actual print time and accuracy for an archive.
  19. Returns dict with actual_time_seconds and time_accuracy.
  20. time_accuracy = (estimated / actual) * 100
  21. - 100% = perfect estimate
  22. - >100% = print was faster than estimated
  23. - <100% = print took longer than estimated
  24. """
  25. result = {"actual_time_seconds": None, "time_accuracy": None}
  26. if archive.started_at and archive.completed_at and archive.status == "completed":
  27. actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
  28. if actual_seconds > 0:
  29. result["actual_time_seconds"] = actual_seconds
  30. if archive.print_time_seconds and archive.print_time_seconds > 0:
  31. # Calculate accuracy as percentage
  32. accuracy = (archive.print_time_seconds / actual_seconds) * 100
  33. result["time_accuracy"] = round(accuracy, 1)
  34. return result
  35. def archive_to_response(
  36. archive: PrintArchive,
  37. duplicates: list[dict] | None = None,
  38. duplicate_count: int = 0,
  39. ) -> dict:
  40. """Convert archive model to response dict with computed fields."""
  41. data = {
  42. "id": archive.id,
  43. "printer_id": archive.printer_id,
  44. "filename": archive.filename,
  45. "file_path": archive.file_path,
  46. "file_size": archive.file_size,
  47. "content_hash": archive.content_hash,
  48. "thumbnail_path": archive.thumbnail_path,
  49. "timelapse_path": archive.timelapse_path,
  50. "duplicates": duplicates,
  51. "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
  52. "print_name": archive.print_name,
  53. "print_time_seconds": archive.print_time_seconds,
  54. "filament_used_grams": archive.filament_used_grams,
  55. "filament_type": archive.filament_type,
  56. "filament_color": archive.filament_color,
  57. "layer_height": archive.layer_height,
  58. "nozzle_diameter": archive.nozzle_diameter,
  59. "bed_temperature": archive.bed_temperature,
  60. "nozzle_temperature": archive.nozzle_temperature,
  61. "status": archive.status,
  62. "started_at": archive.started_at,
  63. "completed_at": archive.completed_at,
  64. "extra_data": archive.extra_data,
  65. "makerworld_url": archive.makerworld_url,
  66. "designer": archive.designer,
  67. "is_favorite": archive.is_favorite,
  68. "tags": archive.tags,
  69. "notes": archive.notes,
  70. "cost": archive.cost,
  71. "photos": archive.photos,
  72. "failure_reason": archive.failure_reason,
  73. "created_at": archive.created_at,
  74. }
  75. # Add computed time accuracy fields
  76. accuracy_data = compute_time_accuracy(archive)
  77. data.update(accuracy_data)
  78. return data
  79. @router.get("/", response_model=list[ArchiveResponse])
  80. async def list_archives(
  81. printer_id: int | None = None,
  82. limit: int = 50,
  83. offset: int = 0,
  84. db: AsyncSession = Depends(get_db),
  85. ):
  86. """List archived prints."""
  87. service = ArchiveService(db)
  88. archives = await service.list_archives(
  89. printer_id=printer_id,
  90. limit=limit,
  91. offset=offset,
  92. )
  93. # Get set of hashes that have duplicates (efficient single query)
  94. duplicate_hashes = await service.get_duplicate_hashes()
  95. # Mark archives that have duplicates
  96. result = []
  97. for a in archives:
  98. has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False
  99. result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
  100. return result
  101. @router.get("/stats", response_model=ArchiveStats)
  102. async def get_archive_stats(db: AsyncSession = Depends(get_db)):
  103. """Get statistics across all archives."""
  104. # Total counts
  105. total_result = await db.execute(select(func.count(PrintArchive.id)))
  106. total_prints = total_result.scalar() or 0
  107. successful_result = await db.execute(
  108. select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")
  109. )
  110. successful_prints = successful_result.scalar() or 0
  111. failed_result = await db.execute(
  112. select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")
  113. )
  114. failed_prints = failed_result.scalar() or 0
  115. # Totals
  116. time_result = await db.execute(
  117. select(func.sum(PrintArchive.print_time_seconds))
  118. )
  119. total_time = (time_result.scalar() or 0) / 3600 # Convert to hours
  120. filament_result = await db.execute(
  121. select(func.sum(PrintArchive.filament_used_grams))
  122. )
  123. total_filament = filament_result.scalar() or 0
  124. cost_result = await db.execute(
  125. select(func.sum(PrintArchive.cost))
  126. )
  127. total_cost = cost_result.scalar() or 0
  128. # By filament type (split comma-separated values for multi-material prints)
  129. filament_type_result = await db.execute(
  130. select(PrintArchive.filament_type)
  131. .where(PrintArchive.filament_type.isnot(None))
  132. )
  133. prints_by_filament: dict[str, int] = {}
  134. for (filament_types,) in filament_type_result.all():
  135. # Split by comma and count each type
  136. for ftype in filament_types.split(","):
  137. ftype = ftype.strip()
  138. if ftype:
  139. prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1
  140. # By printer
  141. printer_result = await db.execute(
  142. select(PrintArchive.printer_id, func.count(PrintArchive.id))
  143. .group_by(PrintArchive.printer_id)
  144. )
  145. prints_by_printer = {str(k): v for k, v in printer_result.all()}
  146. # Time accuracy statistics
  147. # Get all completed archives with both estimated and actual times
  148. accuracy_result = await db.execute(
  149. select(PrintArchive)
  150. .where(PrintArchive.status == "completed")
  151. .where(PrintArchive.print_time_seconds.isnot(None))
  152. .where(PrintArchive.started_at.isnot(None))
  153. .where(PrintArchive.completed_at.isnot(None))
  154. )
  155. archives_with_times = list(accuracy_result.scalars().all())
  156. average_accuracy = None
  157. accuracy_by_printer: dict[str, float] = {}
  158. if archives_with_times:
  159. accuracies = []
  160. printer_accuracies: dict[str, list[float]] = {}
  161. for archive in archives_with_times:
  162. acc_data = compute_time_accuracy(archive)
  163. if acc_data["time_accuracy"] is not None:
  164. accuracies.append(acc_data["time_accuracy"])
  165. # Group by printer
  166. printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
  167. if printer_key not in printer_accuracies:
  168. printer_accuracies[printer_key] = []
  169. printer_accuracies[printer_key].append(acc_data["time_accuracy"])
  170. if accuracies:
  171. average_accuracy = round(sum(accuracies) / len(accuracies), 1)
  172. # Calculate per-printer averages
  173. for printer_key, accs in printer_accuracies.items():
  174. accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
  175. # Energy totals
  176. energy_kwh_result = await db.execute(
  177. select(func.sum(PrintArchive.energy_kwh))
  178. )
  179. total_energy_kwh = energy_kwh_result.scalar() or 0
  180. energy_cost_result = await db.execute(
  181. select(func.sum(PrintArchive.energy_cost))
  182. )
  183. total_energy_cost = energy_cost_result.scalar() or 0
  184. return ArchiveStats(
  185. total_prints=total_prints,
  186. successful_prints=successful_prints,
  187. failed_prints=failed_prints,
  188. total_print_time_hours=round(total_time, 1),
  189. total_filament_grams=round(total_filament, 1),
  190. total_cost=round(total_cost, 2),
  191. prints_by_filament_type=prints_by_filament,
  192. prints_by_printer=prints_by_printer,
  193. average_time_accuracy=average_accuracy,
  194. time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
  195. total_energy_kwh=round(total_energy_kwh, 3),
  196. total_energy_cost=round(total_energy_cost, 2),
  197. )
  198. @router.get("/{archive_id}", response_model=ArchiveResponse)
  199. async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
  200. """Get a specific archive."""
  201. service = ArchiveService(db)
  202. archive = await service.get_archive(archive_id)
  203. if not archive:
  204. raise HTTPException(404, "Archive not found")
  205. # Find duplicates
  206. makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
  207. duplicates = await service.find_duplicates(
  208. archive_id=archive.id,
  209. content_hash=archive.content_hash,
  210. print_name=archive.print_name,
  211. makerworld_model_id=makerworld_id,
  212. )
  213. return archive_to_response(archive, duplicates)
  214. @router.patch("/{archive_id}", response_model=ArchiveResponse)
  215. async def update_archive(
  216. archive_id: int,
  217. update_data: ArchiveUpdate,
  218. db: AsyncSession = Depends(get_db),
  219. ):
  220. """Update archive metadata (tags, notes, cost, is_favorite)."""
  221. result = await db.execute(
  222. select(PrintArchive).where(PrintArchive.id == archive_id)
  223. )
  224. archive = result.scalar_one_or_none()
  225. if not archive:
  226. raise HTTPException(404, "Archive not found")
  227. for field, value in update_data.model_dump(exclude_unset=True).items():
  228. setattr(archive, field, value)
  229. await db.commit()
  230. await db.refresh(archive)
  231. return archive
  232. @router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
  233. async def toggle_favorite(
  234. archive_id: int,
  235. db: AsyncSession = Depends(get_db),
  236. ):
  237. """Toggle favorite status for an archive."""
  238. result = await db.execute(
  239. select(PrintArchive).where(PrintArchive.id == archive_id)
  240. )
  241. archive = result.scalar_one_or_none()
  242. if not archive:
  243. raise HTTPException(404, "Archive not found")
  244. archive.is_favorite = not archive.is_favorite
  245. await db.commit()
  246. await db.refresh(archive)
  247. return archive
  248. @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
  249. async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
  250. """Rescan the 3MF file and update metadata."""
  251. from backend.app.services.archive import ThreeMFParser
  252. result = await db.execute(
  253. select(PrintArchive).where(PrintArchive.id == archive_id)
  254. )
  255. archive = result.scalar_one_or_none()
  256. if not archive:
  257. raise HTTPException(404, "Archive not found")
  258. file_path = settings.base_dir / archive.file_path
  259. if not file_path.exists():
  260. raise HTTPException(404, "Archive file not found")
  261. # Parse the 3MF file
  262. parser = ThreeMFParser(file_path)
  263. metadata = parser.parse()
  264. # Update fields from metadata
  265. if metadata.get("filament_type"):
  266. archive.filament_type = metadata["filament_type"]
  267. if metadata.get("filament_color"):
  268. archive.filament_color = metadata["filament_color"]
  269. if metadata.get("print_time_seconds"):
  270. archive.print_time_seconds = metadata["print_time_seconds"]
  271. if metadata.get("filament_used_grams"):
  272. archive.filament_used_grams = metadata["filament_used_grams"]
  273. if metadata.get("layer_height"):
  274. archive.layer_height = metadata["layer_height"]
  275. if metadata.get("nozzle_diameter"):
  276. archive.nozzle_diameter = metadata["nozzle_diameter"]
  277. if metadata.get("bed_temperature"):
  278. archive.bed_temperature = metadata["bed_temperature"]
  279. if metadata.get("nozzle_temperature"):
  280. archive.nozzle_temperature = metadata["nozzle_temperature"]
  281. if metadata.get("makerworld_url"):
  282. archive.makerworld_url = metadata["makerworld_url"]
  283. if metadata.get("designer"):
  284. archive.designer = metadata["designer"]
  285. # Calculate cost based on filament usage and type
  286. if archive.filament_used_grams and archive.filament_type:
  287. primary_type = archive.filament_type.split(",")[0].strip()
  288. filament_result = await db.execute(
  289. select(Filament).where(Filament.type == primary_type).limit(1)
  290. )
  291. filament = filament_result.scalar_one_or_none()
  292. if filament:
  293. archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2)
  294. else:
  295. archive.cost = round((archive.filament_used_grams / 1000) * 25.0, 2)
  296. await db.commit()
  297. await db.refresh(archive)
  298. return archive
  299. @router.post("/recalculate-costs")
  300. async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
  301. """Recalculate costs for all archives based on filament usage and prices."""
  302. result = await db.execute(select(PrintArchive))
  303. archives = list(result.scalars().all())
  304. # Load all filaments for lookup
  305. filament_result = await db.execute(select(Filament))
  306. filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}
  307. default_cost_per_kg = 25.0
  308. updated = 0
  309. for archive in archives:
  310. if archive.filament_used_grams and archive.filament_type:
  311. primary_type = archive.filament_type.split(",")[0].strip()
  312. cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
  313. new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
  314. if archive.cost != new_cost:
  315. archive.cost = new_cost
  316. updated += 1
  317. await db.commit()
  318. return {"message": f"Recalculated costs for {updated} archives", "updated": updated}
  319. @router.post("/rescan-all")
  320. async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
  321. """Rescan all archives and update their metadata."""
  322. from backend.app.services.archive import ThreeMFParser
  323. result = await db.execute(select(PrintArchive))
  324. archives = list(result.scalars().all())
  325. updated = 0
  326. errors = []
  327. for archive in archives:
  328. try:
  329. file_path = settings.base_dir / archive.file_path
  330. if not file_path.exists():
  331. errors.append({"id": archive.id, "error": "File not found"})
  332. continue
  333. parser = ThreeMFParser(file_path)
  334. metadata = parser.parse()
  335. if metadata.get("filament_type"):
  336. archive.filament_type = metadata["filament_type"]
  337. if metadata.get("filament_color"):
  338. archive.filament_color = metadata["filament_color"]
  339. if metadata.get("print_time_seconds"):
  340. archive.print_time_seconds = metadata["print_time_seconds"]
  341. if metadata.get("filament_used_grams"):
  342. archive.filament_used_grams = metadata["filament_used_grams"]
  343. if metadata.get("layer_height"):
  344. archive.layer_height = metadata["layer_height"]
  345. if metadata.get("nozzle_diameter"):
  346. archive.nozzle_diameter = metadata["nozzle_diameter"]
  347. if metadata.get("makerworld_url"):
  348. archive.makerworld_url = metadata["makerworld_url"]
  349. if metadata.get("designer"):
  350. archive.designer = metadata["designer"]
  351. updated += 1
  352. except Exception as e:
  353. errors.append({"id": archive.id, "error": str(e)})
  354. await db.commit()
  355. return {"updated": updated, "errors": errors}
  356. @router.get("/{archive_id}/duplicates")
  357. async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
  358. """Get duplicates for a specific archive."""
  359. service = ArchiveService(db)
  360. archive = await service.get_archive(archive_id)
  361. if not archive:
  362. raise HTTPException(404, "Archive not found")
  363. makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
  364. duplicates = await service.find_duplicates(
  365. archive_id=archive.id,
  366. content_hash=archive.content_hash,
  367. print_name=archive.print_name,
  368. makerworld_model_id=makerworld_id,
  369. )
  370. return {"duplicates": duplicates, "count": len(duplicates)}
  371. @router.post("/backfill-hashes")
  372. async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
  373. """Compute and store content hashes for all archives missing them."""
  374. result = await db.execute(
  375. select(PrintArchive).where(PrintArchive.content_hash.is_(None))
  376. )
  377. archives = list(result.scalars().all())
  378. updated = 0
  379. errors = []
  380. for archive in archives:
  381. try:
  382. file_path = settings.base_dir / archive.file_path
  383. if not file_path.exists():
  384. errors.append({"id": archive.id, "error": "File not found"})
  385. continue
  386. archive.content_hash = ArchiveService.compute_file_hash(file_path)
  387. updated += 1
  388. except Exception as e:
  389. errors.append({"id": archive.id, "error": str(e)})
  390. await db.commit()
  391. return {"updated": updated, "errors": errors}
  392. @router.delete("/{archive_id}")
  393. async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
  394. """Delete an archive."""
  395. service = ArchiveService(db)
  396. if not await service.delete_archive(archive_id):
  397. raise HTTPException(404, "Archive not found")
  398. return {"status": "deleted"}
  399. @router.get("/{archive_id}/download")
  400. async def download_archive(
  401. archive_id: int,
  402. inline: bool = False,
  403. db: AsyncSession = Depends(get_db),
  404. ):
  405. """Download the 3MF file."""
  406. service = ArchiveService(db)
  407. archive = await service.get_archive(archive_id)
  408. if not archive:
  409. raise HTTPException(404, "Archive not found")
  410. file_path = settings.base_dir / archive.file_path
  411. if not file_path.exists():
  412. raise HTTPException(404, "File not found")
  413. # Use inline disposition to let browser/OS handle file association
  414. content_disposition = "inline" if inline else "attachment"
  415. return FileResponse(
  416. path=file_path,
  417. filename=archive.filename,
  418. media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  419. content_disposition_type=content_disposition,
  420. )
  421. @router.get("/{archive_id}/file/{filename}")
  422. async def download_archive_with_filename(
  423. archive_id: int,
  424. filename: str,
  425. db: AsyncSession = Depends(get_db),
  426. ):
  427. """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
  428. service = ArchiveService(db)
  429. archive = await service.get_archive(archive_id)
  430. if not archive:
  431. raise HTTPException(404, "Archive not found")
  432. file_path = settings.base_dir / archive.file_path
  433. if not file_path.exists():
  434. raise HTTPException(404, "File not found")
  435. return FileResponse(
  436. path=file_path,
  437. filename=archive.filename,
  438. media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  439. )
  440. @router.get("/{archive_id}/thumbnail")
  441. async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
  442. """Get the thumbnail image."""
  443. service = ArchiveService(db)
  444. archive = await service.get_archive(archive_id)
  445. if not archive or not archive.thumbnail_path:
  446. raise HTTPException(404, "Thumbnail not found")
  447. thumb_path = settings.base_dir / archive.thumbnail_path
  448. if not thumb_path.exists():
  449. raise HTTPException(404, "Thumbnail file not found")
  450. return FileResponse(path=thumb_path, media_type="image/png")
  451. @router.get("/{archive_id}/timelapse")
  452. async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
  453. """Get the timelapse video."""
  454. service = ArchiveService(db)
  455. archive = await service.get_archive(archive_id)
  456. if not archive or not archive.timelapse_path:
  457. raise HTTPException(404, "Timelapse not found")
  458. timelapse_path = settings.base_dir / archive.timelapse_path
  459. if not timelapse_path.exists():
  460. raise HTTPException(404, "Timelapse file not found")
  461. return FileResponse(
  462. path=timelapse_path,
  463. media_type="video/mp4",
  464. filename=f"{archive.print_name or 'timelapse'}.mp4",
  465. )
  466. @router.post("/{archive_id}/timelapse/scan")
  467. async def scan_timelapse(
  468. archive_id: int,
  469. db: AsyncSession = Depends(get_db),
  470. ):
  471. """Scan printer for timelapse matching this archive and attach it."""
  472. from backend.app.models.printer import Printer
  473. from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async
  474. service = ArchiveService(db)
  475. archive = await service.get_archive(archive_id)
  476. if not archive:
  477. raise HTTPException(404, "Archive not found")
  478. if archive.timelapse_path:
  479. return {"status": "exists", "message": "Timelapse already attached"}
  480. if not archive.printer_id:
  481. raise HTTPException(400, "Archive has no associated printer")
  482. # Get printer
  483. result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
  484. printer = result.scalar_one_or_none()
  485. if not printer:
  486. raise HTTPException(404, "Printer not found")
  487. # Get base name from archive filename (without .3mf extension)
  488. base_name = Path(archive.filename).stem
  489. # Scan timelapse directory on printer
  490. # Try both /timelapse and /timelapse/video (different printer models use different paths)
  491. files = []
  492. for timelapse_path in ["/timelapse", "/timelapse/video"]:
  493. try:
  494. files = await list_files_async(printer.ip_address, printer.access_code, timelapse_path)
  495. if files:
  496. break
  497. except Exception:
  498. continue
  499. if not files:
  500. raise HTTPException(500, "Failed to connect to printer or no timelapse directory found")
  501. # Look for matching timelapse
  502. matching_file = None
  503. mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
  504. # Strategy 1: Match by print name in filename
  505. for f in mp4_files:
  506. fname = f.get("name", "")
  507. if base_name.lower() in fname.lower():
  508. matching_file = f
  509. break
  510. # Strategy 2: Match by timestamp proximity
  511. if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
  512. import re
  513. from datetime import datetime, timedelta
  514. archive_time = archive.started_at or archive.completed_at or archive.created_at
  515. best_match = None
  516. best_diff = timedelta(hours=24) # Max 24 hour difference
  517. for f in mp4_files:
  518. fname = f.get("name", "")
  519. # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
  520. match = re.search(r'(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})', fname)
  521. if match:
  522. try:
  523. file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
  524. # Timelapse is usually created at print end, so compare to completed_at or created_at
  525. compare_time = archive.completed_at or archive.created_at
  526. if compare_time:
  527. # Bambu printers use China Standard Time (UTC+8) for filenames
  528. # Try matching with CST offset adjustment
  529. diff_direct = abs(file_time - compare_time)
  530. # Also try with 8-hour offset (CST to UTC-ish local times)
  531. diff_cst_adjusted = abs(file_time - timedelta(hours=8) - compare_time)
  532. diff = min(diff_direct, diff_cst_adjusted)
  533. if diff < best_diff:
  534. best_diff = diff
  535. best_match = f
  536. except ValueError:
  537. continue
  538. if best_match and best_diff < timedelta(hours=2): # Within 2 hours
  539. matching_file = best_match
  540. # Strategy 3: If only one timelapse exists and archive was recently completed, use it
  541. # This handles cases where printer clock is wrong or timezone issues exist
  542. if not matching_file and len(mp4_files) == 1:
  543. from datetime import datetime, timedelta
  544. archive_completed = archive.completed_at or archive.created_at
  545. if archive_completed:
  546. time_since_completion = datetime.now() - archive_completed
  547. # If archive was completed within the last hour, assume the single timelapse is for it
  548. if time_since_completion < timedelta(hours=1):
  549. matching_file = mp4_files[0]
  550. logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
  551. if not matching_file:
  552. return {"status": "not_found", "message": "No matching timelapse found on printer"}
  553. # Download the timelapse - use the full path from the file listing
  554. remote_path = matching_file.get('path') or f"/timelapse/{matching_file['name']}"
  555. timelapse_data = await download_file_bytes_async(
  556. printer.ip_address, printer.access_code, remote_path
  557. )
  558. if not timelapse_data:
  559. raise HTTPException(500, "Failed to download timelapse")
  560. # Attach timelapse to archive
  561. success = await service.attach_timelapse(
  562. archive_id, timelapse_data, matching_file["name"]
  563. )
  564. if not success:
  565. raise HTTPException(500, "Failed to attach timelapse")
  566. return {
  567. "status": "attached",
  568. "message": f"Timelapse '{matching_file['name']}' attached successfully",
  569. "filename": matching_file["name"],
  570. }
  571. @router.post("/{archive_id}/timelapse/upload")
  572. async def upload_timelapse(
  573. archive_id: int,
  574. file: UploadFile = File(...),
  575. db: AsyncSession = Depends(get_db),
  576. ):
  577. """Manually upload a timelapse video to an archive."""
  578. service = ArchiveService(db)
  579. archive = await service.get_archive(archive_id)
  580. if not archive:
  581. raise HTTPException(404, "Archive not found")
  582. if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
  583. raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)")
  584. content = await file.read()
  585. success = await service.attach_timelapse(archive_id, content, file.filename)
  586. if not success:
  587. raise HTTPException(500, "Failed to attach timelapse")
  588. return {"status": "attached", "filename": file.filename}
  589. # ============================================
  590. # Photo Endpoints
  591. # ============================================
  592. @router.post("/{archive_id}/photos")
  593. async def upload_photo(
  594. archive_id: int,
  595. file: UploadFile = File(...),
  596. db: AsyncSession = Depends(get_db),
  597. ):
  598. """Upload a photo of the printed result."""
  599. result = await db.execute(
  600. select(PrintArchive).where(PrintArchive.id == archive_id)
  601. )
  602. archive = result.scalar_one_or_none()
  603. if not archive:
  604. raise HTTPException(404, "Archive not found")
  605. if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
  606. raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
  607. # Get archive directory
  608. file_path = settings.base_dir / archive.file_path
  609. archive_dir = file_path.parent
  610. photos_dir = archive_dir / "photos"
  611. photos_dir.mkdir(exist_ok=True)
  612. # Generate unique filename
  613. import uuid
  614. ext = Path(file.filename).suffix.lower()
  615. photo_filename = f"{uuid.uuid4().hex[:8]}{ext}"
  616. photo_path = photos_dir / photo_filename
  617. # Save file
  618. content = await file.read()
  619. photo_path.write_bytes(content)
  620. # Update archive photos list (create new list to trigger SQLAlchemy change detection)
  621. photos = list(archive.photos or [])
  622. photos.append(photo_filename)
  623. archive.photos = photos
  624. await db.commit()
  625. await db.refresh(archive)
  626. return {"status": "uploaded", "filename": photo_filename, "photos": archive.photos}
  627. @router.get("/{archive_id}/photos/{filename}")
  628. async def get_photo(
  629. archive_id: int,
  630. filename: str,
  631. db: AsyncSession = Depends(get_db),
  632. ):
  633. """Get a specific photo."""
  634. result = await db.execute(
  635. select(PrintArchive).where(PrintArchive.id == archive_id)
  636. )
  637. archive = result.scalar_one_or_none()
  638. if not archive:
  639. raise HTTPException(404, "Archive not found")
  640. file_path = settings.base_dir / archive.file_path
  641. photo_path = file_path.parent / "photos" / filename
  642. if not photo_path.exists():
  643. raise HTTPException(404, "Photo not found")
  644. # Determine media type
  645. ext = Path(filename).suffix.lower()
  646. media_types = {
  647. ".jpg": "image/jpeg",
  648. ".jpeg": "image/jpeg",
  649. ".png": "image/png",
  650. ".webp": "image/webp",
  651. }
  652. media_type = media_types.get(ext, "image/jpeg")
  653. return FileResponse(path=photo_path, media_type=media_type)
  654. @router.delete("/{archive_id}/photos/{filename}")
  655. async def delete_photo(
  656. archive_id: int,
  657. filename: str,
  658. db: AsyncSession = Depends(get_db),
  659. ):
  660. """Delete a photo."""
  661. result = await db.execute(
  662. select(PrintArchive).where(PrintArchive.id == archive_id)
  663. )
  664. archive = result.scalar_one_or_none()
  665. if not archive:
  666. raise HTTPException(404, "Archive not found")
  667. if not archive.photos or filename not in archive.photos:
  668. raise HTTPException(404, "Photo not found")
  669. # Delete file
  670. file_path = settings.base_dir / archive.file_path
  671. photo_path = file_path.parent / "photos" / filename
  672. if photo_path.exists():
  673. photo_path.unlink()
  674. # Update archive photos list
  675. photos = [p for p in archive.photos if p != filename]
  676. archive.photos = photos if photos else None
  677. await db.commit()
  678. return {"status": "deleted", "photos": archive.photos}
  679. # ============================================
  680. # QR Code Endpoint
  681. # ============================================
  682. @router.get("/{archive_id}/qrcode")
  683. async def get_qrcode(
  684. archive_id: int,
  685. request: Request,
  686. size: int = 200,
  687. db: AsyncSession = Depends(get_db),
  688. ):
  689. """Generate a QR code that links to this archive."""
  690. import qrcode
  691. from qrcode.image.styledpil import StyledPilImage
  692. result = await db.execute(
  693. select(PrintArchive).where(PrintArchive.id == archive_id)
  694. )
  695. archive = result.scalar_one_or_none()
  696. if not archive:
  697. raise HTTPException(404, "Archive not found")
  698. # Build URL to archive detail page
  699. base_url = str(request.base_url).rstrip('/')
  700. archive_url = f"{base_url}/archives?id={archive_id}"
  701. # Generate QR code
  702. qr = qrcode.QRCode(
  703. version=1,
  704. error_correction=qrcode.constants.ERROR_CORRECT_M,
  705. box_size=10,
  706. border=2,
  707. )
  708. qr.add_data(archive_url)
  709. qr.make(fit=True)
  710. img = qr.make_image(fill_color="black", back_color="white")
  711. # Resize if needed
  712. if size != 200:
  713. img = img.resize((size, size))
  714. # Convert to bytes
  715. buffer = io.BytesIO()
  716. img.save(buffer, format="PNG")
  717. buffer.seek(0)
  718. return Response(
  719. content=buffer.getvalue(),
  720. media_type="image/png",
  721. headers={
  722. "Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'
  723. }
  724. )
  725. @router.get("/{archive_id}/capabilities")
  726. async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
  727. """Check what viewing capabilities are available for this 3MF file."""
  728. import json
  729. import re
  730. service = ArchiveService(db)
  731. archive = await service.get_archive(archive_id)
  732. if not archive:
  733. raise HTTPException(404, "Archive not found")
  734. file_path = settings.base_dir / archive.file_path
  735. if not file_path.exists():
  736. raise HTTPException(404, "File not found")
  737. has_model = False
  738. has_gcode = False
  739. build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size
  740. try:
  741. with zipfile.ZipFile(file_path, 'r') as zf:
  742. names = zf.namelist()
  743. # Check for G-code
  744. has_gcode = any(n.startswith('Metadata/') and n.endswith('.gcode') for n in names)
  745. # Check for 3D model - need to look for actual mesh data
  746. for name in names:
  747. if name.endswith('.model'):
  748. try:
  749. content = zf.read(name).decode('utf-8')
  750. # Check if this model file contains actual mesh vertices
  751. if '<vertex' in content or '<mesh' in content:
  752. has_model = True
  753. break
  754. except Exception:
  755. pass
  756. # Extract build volume from project settings
  757. if 'Metadata/project_settings.config' in names:
  758. try:
  759. config_content = zf.read('Metadata/project_settings.config').decode('utf-8')
  760. config_data = json.loads(config_content)
  761. # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
  762. printable_area = config_data.get('printable_area', [])
  763. if printable_area and len(printable_area) >= 3:
  764. # Get max X and Y from the corner coordinates
  765. max_x = 0
  766. max_y = 0
  767. for coord in printable_area:
  768. if 'x' in coord:
  769. parts = coord.split('x')
  770. if len(parts) == 2:
  771. try:
  772. x, y = int(parts[0]), int(parts[1])
  773. max_x = max(max_x, x)
  774. max_y = max(max_y, y)
  775. except ValueError:
  776. pass
  777. if max_x > 0 and max_y > 0:
  778. build_volume["x"] = max_x
  779. build_volume["y"] = max_y
  780. # Parse printable_height
  781. printable_height = config_data.get('printable_height')
  782. if printable_height:
  783. try:
  784. build_volume["z"] = int(printable_height)
  785. except (ValueError, TypeError):
  786. pass
  787. except Exception:
  788. pass
  789. except zipfile.BadZipFile:
  790. raise HTTPException(400, "Invalid 3MF file")
  791. return {
  792. "has_model": has_model,
  793. "has_gcode": has_gcode,
  794. "build_volume": build_volume,
  795. }
  796. @router.get("/{archive_id}/gcode")
  797. async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
  798. """Extract and return G-code from the 3MF file."""
  799. service = ArchiveService(db)
  800. archive = await service.get_archive(archive_id)
  801. if not archive:
  802. raise HTTPException(404, "Archive not found")
  803. file_path = settings.base_dir / archive.file_path
  804. if not file_path.exists():
  805. raise HTTPException(404, "File not found")
  806. try:
  807. with zipfile.ZipFile(file_path, 'r') as zf:
  808. # Bambu 3MF files store G-code in Metadata/plate_X.gcode
  809. gcode_files = [n for n in zf.namelist() if n.startswith('Metadata/') and n.endswith('.gcode')]
  810. if not gcode_files:
  811. raise HTTPException(
  812. 404,
  813. "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio."
  814. )
  815. # Get the first plate's G-code (usually plate_1.gcode)
  816. gcode_content = zf.read(gcode_files[0]).decode('utf-8')
  817. return Response(content=gcode_content, media_type="text/plain")
  818. except zipfile.BadZipFile:
  819. raise HTTPException(400, "Invalid 3MF file")
  820. except HTTPException:
  821. raise
  822. except Exception as e:
  823. raise HTTPException(500, f"Error extracting G-code: {str(e)}")
  824. @router.post("/upload")
  825. async def upload_archive(
  826. file: UploadFile = File(...),
  827. printer_id: int | None = None,
  828. db: AsyncSession = Depends(get_db),
  829. ):
  830. """Manually upload a 3MF file to archive."""
  831. if not file.filename or not file.filename.endswith(".3mf"):
  832. raise HTTPException(400, "File must be a .3mf file")
  833. # Save uploaded file temporarily
  834. temp_path = settings.archive_dir / "temp" / file.filename
  835. temp_path.parent.mkdir(parents=True, exist_ok=True)
  836. try:
  837. content = await file.read()
  838. temp_path.write_bytes(content)
  839. service = ArchiveService(db)
  840. archive = await service.archive_print(
  841. printer_id=printer_id,
  842. source_file=temp_path,
  843. )
  844. if not archive:
  845. raise HTTPException(400, "Failed to archive file")
  846. return ArchiveResponse.model_validate(archive)
  847. finally:
  848. if temp_path.exists():
  849. temp_path.unlink()
  850. @router.post("/upload-bulk")
  851. async def upload_archives_bulk(
  852. files: list[UploadFile] = File(...),
  853. printer_id: int | None = None,
  854. db: AsyncSession = Depends(get_db),
  855. ):
  856. """Bulk upload multiple 3MF files to archive."""
  857. results = []
  858. errors = []
  859. for file in files:
  860. if not file.filename or not file.filename.endswith(".3mf"):
  861. errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"})
  862. continue
  863. temp_path = settings.archive_dir / "temp" / file.filename
  864. temp_path.parent.mkdir(parents=True, exist_ok=True)
  865. try:
  866. content = await file.read()
  867. temp_path.write_bytes(content)
  868. service = ArchiveService(db)
  869. archive = await service.archive_print(
  870. printer_id=printer_id,
  871. source_file=temp_path,
  872. )
  873. if archive:
  874. results.append({
  875. "filename": file.filename,
  876. "id": archive.id,
  877. "status": "success",
  878. })
  879. else:
  880. errors.append({"filename": file.filename, "error": "Failed to process"})
  881. except Exception as e:
  882. errors.append({"filename": file.filename, "error": str(e)})
  883. finally:
  884. if temp_path.exists():
  885. temp_path.unlink()
  886. return {
  887. "uploaded": len(results),
  888. "failed": len(errors),
  889. "results": results,
  890. "errors": errors,
  891. }
  892. @router.post("/{archive_id}/reprint")
  893. async def reprint_archive(
  894. archive_id: int,
  895. printer_id: int,
  896. db: AsyncSession = Depends(get_db),
  897. ):
  898. """Send an archived 3MF file to a printer and start printing."""
  899. from backend.app.models.printer import Printer
  900. from backend.app.services.bambu_ftp import upload_file_async
  901. from backend.app.services.printer_manager import printer_manager
  902. # Get archive
  903. service = ArchiveService(db)
  904. archive = await service.get_archive(archive_id)
  905. if not archive:
  906. raise HTTPException(404, "Archive not found")
  907. # Get printer
  908. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  909. printer = result.scalar_one_or_none()
  910. if not printer:
  911. raise HTTPException(404, "Printer not found")
  912. # Check printer is connected
  913. if not printer_manager.is_connected(printer_id):
  914. raise HTTPException(400, "Printer is not connected")
  915. # Get the 3MF file path
  916. file_path = settings.base_dir / archive.file_path
  917. if not file_path.exists():
  918. raise HTTPException(404, "Archive file not found")
  919. # Upload file to printer via FTP
  920. remote_filename = archive.filename
  921. remote_path = f"/cache/{remote_filename}"
  922. uploaded = await upload_file_async(
  923. printer.ip_address,
  924. printer.access_code,
  925. file_path,
  926. remote_path,
  927. )
  928. if not uploaded:
  929. raise HTTPException(500, "Failed to upload file to printer")
  930. # Start the print
  931. started = printer_manager.start_print(printer_id, remote_filename)
  932. if not started:
  933. raise HTTPException(500, "Failed to start print")
  934. return {
  935. "status": "printing",
  936. "printer_id": printer_id,
  937. "archive_id": archive_id,
  938. "filename": archive.filename,
  939. }
  940. # =============================================================================
  941. # Project Page API
  942. # =============================================================================
  943. @router.get("/{archive_id}/project-page")
  944. async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
  945. """Get the project page data from the 3MF file."""
  946. from backend.app.services.archive import ProjectPageParser
  947. from backend.app.schemas.archive import ProjectPageResponse
  948. service = ArchiveService(db)
  949. archive = await service.get_archive(archive_id)
  950. if not archive:
  951. raise HTTPException(404, "Archive not found")
  952. file_path = settings.base_dir / archive.file_path
  953. if not file_path.exists():
  954. raise HTTPException(404, "Archive file not found")
  955. parser = ProjectPageParser(file_path)
  956. data = parser.parse(archive_id)
  957. return ProjectPageResponse(**data)
  958. @router.patch("/{archive_id}/project-page")
  959. async def update_project_page(
  960. archive_id: int,
  961. update_data: dict,
  962. db: AsyncSession = Depends(get_db),
  963. ):
  964. """Update project page metadata in the 3MF file."""
  965. from backend.app.services.archive import ProjectPageParser
  966. service = ArchiveService(db)
  967. archive = await service.get_archive(archive_id)
  968. if not archive:
  969. raise HTTPException(404, "Archive not found")
  970. file_path = settings.base_dir / archive.file_path
  971. if not file_path.exists():
  972. raise HTTPException(404, "Archive file not found")
  973. parser = ProjectPageParser(file_path)
  974. success = parser.update_metadata(update_data)
  975. if not success:
  976. raise HTTPException(500, "Failed to update project page")
  977. # Return updated data
  978. data = parser.parse(archive_id)
  979. return data
  980. @router.get("/{archive_id}/project-image/{image_path:path}")
  981. async def get_project_image(
  982. archive_id: int,
  983. image_path: str,
  984. db: AsyncSession = Depends(get_db),
  985. ):
  986. """Get an image from the 3MF project page."""
  987. from backend.app.services.archive import ProjectPageParser
  988. service = ArchiveService(db)
  989. archive = await service.get_archive(archive_id)
  990. if not archive:
  991. raise HTTPException(404, "Archive not found")
  992. file_path = settings.base_dir / archive.file_path
  993. if not file_path.exists():
  994. raise HTTPException(404, "Archive file not found")
  995. parser = ProjectPageParser(file_path)
  996. result = parser.get_image(image_path)
  997. if not result:
  998. raise HTTPException(404, "Image not found in 3MF file")
  999. image_data, content_type = result
  1000. return Response(
  1001. content=image_data,
  1002. media_type=content_type,
  1003. headers={"Cache-Control": "max-age=3600"},
  1004. )