archives.py 42 KB

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