archives.py 40 KB

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