archives.py 49 KB

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