archives.py 48 KB

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