archives.py 57 KB

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