archive.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import json
  2. import zipfile
  3. import shutil
  4. from datetime import datetime
  5. from pathlib import Path
  6. from xml.etree import ElementTree as ET
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from sqlalchemy import select
  9. from backend.app.core.config import settings
  10. from backend.app.models.archive import PrintArchive
  11. from backend.app.models.printer import Printer
  12. class ThreeMFParser:
  13. """Parser for Bambu Lab 3MF files."""
  14. def __init__(self, file_path: Path):
  15. self.file_path = file_path
  16. self.metadata: dict = {}
  17. def parse(self) -> dict:
  18. """Extract metadata from 3MF file."""
  19. try:
  20. with zipfile.ZipFile(self.file_path, "r") as zf:
  21. self._parse_slice_info(zf)
  22. self._parse_project_settings(zf)
  23. self._parse_3dmodel(zf)
  24. self._extract_thumbnail(zf)
  25. # Prefer slice_info for colors (shows ALL filaments actually used in print)
  26. # project_settings may filter out "support" filaments incorrectly
  27. if self.metadata.get("_slice_filament_color"):
  28. self.metadata["filament_color"] = self.metadata["_slice_filament_color"]
  29. if not self.metadata.get("filament_type") and self.metadata.get("_slice_filament_type"):
  30. self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
  31. # Clean up internal keys
  32. self.metadata.pop("_slice_filament_type", None)
  33. self.metadata.pop("_slice_filament_color", None)
  34. except Exception:
  35. pass
  36. return self.metadata
  37. def _parse_slice_info(self, zf: zipfile.ZipFile):
  38. """Parse slice_info.config for print settings."""
  39. try:
  40. if "Metadata/slice_info.config" in zf.namelist():
  41. content = zf.read("Metadata/slice_info.config").decode()
  42. root = ET.fromstring(content)
  43. # Get first plate's metadata
  44. plate = root.find(".//plate")
  45. if plate is not None:
  46. # Get prediction and weight from metadata elements
  47. for meta in plate.findall("metadata"):
  48. key = meta.get("key")
  49. value = meta.get("value")
  50. if key == "prediction" and value:
  51. self.metadata["print_time_seconds"] = int(value)
  52. elif key == "weight" and value:
  53. self.metadata["filament_used_grams"] = float(value)
  54. # Get filament info from ALL filaments actually used in the print
  55. # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
  56. filaments = root.findall(".//filament")
  57. if filaments:
  58. # Collect all unique filament types and colors
  59. types = []
  60. colors = []
  61. for f in filaments:
  62. ftype = f.get("type")
  63. fcolor = f.get("color")
  64. if ftype and ftype not in types:
  65. types.append(ftype)
  66. if fcolor and fcolor not in colors:
  67. colors.append(fcolor)
  68. if types:
  69. self.metadata["_slice_filament_type"] = ", ".join(types)
  70. if colors:
  71. self.metadata["_slice_filament_color"] = ",".join(colors)
  72. except Exception:
  73. pass
  74. def _parse_project_settings(self, zf: zipfile.ZipFile):
  75. """Parse project settings for print configuration."""
  76. try:
  77. if "Metadata/project_settings.config" in zf.namelist():
  78. content = zf.read("Metadata/project_settings.config").decode()
  79. try:
  80. data = json.loads(content)
  81. self._extract_filament_info(data)
  82. self._extract_print_settings(data)
  83. except json.JSONDecodeError:
  84. pass
  85. except Exception:
  86. pass
  87. def _extract_filament_info(self, data: dict):
  88. """Extract filament info, preferring non-support filaments."""
  89. try:
  90. filament_types = data.get("filament_type", [])
  91. filament_colors = data.get("filament_colour", [])
  92. filament_is_support = data.get("filament_is_support", [])
  93. if not filament_types:
  94. return
  95. # Collect all non-support filaments
  96. non_support_types = []
  97. non_support_colors = []
  98. for i, ftype in enumerate(filament_types):
  99. is_support = filament_is_support[i] if i < len(filament_is_support) else '0'
  100. if is_support == '0':
  101. if ftype and ftype not in non_support_types:
  102. non_support_types.append(ftype)
  103. if i < len(filament_colors) and filament_colors[i]:
  104. color = filament_colors[i]
  105. if color not in non_support_colors:
  106. non_support_colors.append(color)
  107. # Fallback to first filament if all are support
  108. if not non_support_types and filament_types:
  109. non_support_types = [filament_types[0]]
  110. if not non_support_colors and filament_colors:
  111. non_support_colors = [filament_colors[0]]
  112. # Store filament type(s)
  113. if non_support_types:
  114. self.metadata["filament_type"] = ", ".join(non_support_types)
  115. # Store all colors as comma-separated (for multi-color display)
  116. if non_support_colors:
  117. self.metadata["filament_color"] = ",".join(non_support_colors)
  118. except Exception:
  119. pass
  120. def _extract_print_settings(self, data: dict):
  121. """Extract print settings from JSON config."""
  122. try:
  123. # Layer height - usually an array, get first value
  124. if "layer_height" in data:
  125. val = data["layer_height"]
  126. if isinstance(val, list) and val:
  127. self.metadata["layer_height"] = float(val[0])
  128. elif isinstance(val, (int, float, str)):
  129. self.metadata["layer_height"] = float(val)
  130. # Nozzle diameter
  131. if "nozzle_diameter" in data:
  132. val = data["nozzle_diameter"]
  133. if isinstance(val, list) and val:
  134. self.metadata["nozzle_diameter"] = float(val[0])
  135. elif isinstance(val, (int, float, str)):
  136. self.metadata["nozzle_diameter"] = float(val)
  137. # Bed temperature - first layer or regular
  138. for key in ["bed_temperature_initial_layer", "bed_temperature"]:
  139. if key in data:
  140. val = data[key]
  141. if isinstance(val, list) and val:
  142. self.metadata["bed_temperature"] = int(float(val[0]))
  143. elif isinstance(val, (int, float, str)):
  144. self.metadata["bed_temperature"] = int(float(val))
  145. break
  146. # Nozzle temperature
  147. for key in ["nozzle_temperature_initial_layer", "nozzle_temperature"]:
  148. if key in data:
  149. val = data[key]
  150. if isinstance(val, list) and val:
  151. self.metadata["nozzle_temperature"] = int(float(val[0]))
  152. elif isinstance(val, (int, float, str)):
  153. self.metadata["nozzle_temperature"] = int(float(val))
  154. break
  155. except Exception:
  156. pass
  157. def _extract_settings_from_content(self, content: str):
  158. """Extract print settings from config content."""
  159. settings_map = {
  160. "layer_height": ("layer_height", float),
  161. "nozzle_diameter": ("nozzle_diameter", float),
  162. "bed_temperature": ("bed_temperature", int),
  163. "nozzle_temperature": ("nozzle_temperature", int),
  164. }
  165. for key, (search_key, converter) in settings_map.items():
  166. if key not in self.metadata:
  167. try:
  168. # Try JSON format
  169. if f'"{search_key}"' in content:
  170. start = content.find(f'"{search_key}"')
  171. value_start = content.find(":", start) + 1
  172. value_end = content.find(",", value_start)
  173. if value_end == -1:
  174. value_end = content.find("}", value_start)
  175. value = content[value_start:value_end].strip().strip('"')
  176. self.metadata[key] = converter(value)
  177. except Exception:
  178. pass
  179. def _parse_3dmodel(self, zf: zipfile.ZipFile):
  180. """Parse 3D/3dmodel.model for MakerWorld metadata."""
  181. import re
  182. try:
  183. model_path = "3D/3dmodel.model"
  184. if model_path not in zf.namelist():
  185. return
  186. content = zf.read(model_path).decode("utf-8", errors="ignore")
  187. # Parse XML metadata elements
  188. # MakerWorld adds metadata like: <metadata name="Designer">username</metadata>
  189. metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
  190. matches = re.findall(metadata_pattern, content)
  191. makerworld_fields = {}
  192. for name, value in matches:
  193. makerworld_fields[name] = value.strip()
  194. # Check for direct MakerWorld URL in content
  195. url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)'
  196. url_match = re.search(url_pattern, content)
  197. if url_match:
  198. self.metadata["makerworld_url"] = url_match.group(0)
  199. self.metadata["makerworld_model_id"] = url_match.group(1)
  200. # Extract model ID from DSM reference in image URLs
  201. # Format: https://makerworld.bblmw.com/makerworld/model/DSM00000001275614/...
  202. # The numeric part (1275614) is the MakerWorld model ID
  203. if "makerworld_url" not in self.metadata:
  204. dsm_pattern = r'DSM0+(\d+)'
  205. dsm_match = re.search(dsm_pattern, content)
  206. if dsm_match:
  207. model_id = dsm_match.group(1)
  208. self.metadata["makerworld_url"] = f"https://makerworld.com/en/models/{model_id}"
  209. self.metadata["makerworld_model_id"] = model_id
  210. # Store designer info
  211. if "Designer" in makerworld_fields:
  212. self.metadata["designer"] = makerworld_fields["Designer"]
  213. if "Title" in makerworld_fields:
  214. self.metadata["print_name"] = makerworld_fields["Title"]
  215. except Exception:
  216. pass
  217. def _extract_thumbnail(self, zf: zipfile.ZipFile):
  218. """Extract thumbnail image from 3MF."""
  219. thumbnail_paths = [
  220. "Metadata/plate_1.png",
  221. "Metadata/thumbnail.png",
  222. "Metadata/model_thumbnail.png",
  223. ]
  224. for thumb_path in thumbnail_paths:
  225. if thumb_path in zf.namelist():
  226. self.metadata["_thumbnail_data"] = zf.read(thumb_path)
  227. self.metadata["_thumbnail_ext"] = ".png"
  228. break
  229. class ArchiveService:
  230. """Service for archiving print jobs."""
  231. def __init__(self, db: AsyncSession):
  232. self.db = db
  233. async def archive_print(
  234. self,
  235. printer_id: int | None,
  236. source_file: Path,
  237. print_data: dict | None = None,
  238. ) -> PrintArchive | None:
  239. """Archive a 3MF file with metadata."""
  240. # Verify printer exists if specified
  241. if printer_id is not None:
  242. result = await self.db.execute(
  243. select(Printer).where(Printer.id == printer_id)
  244. )
  245. printer = result.scalar_one_or_none()
  246. if not printer:
  247. return None
  248. # Create archive directory structure
  249. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  250. archive_name = f"{timestamp}_{source_file.stem}"
  251. # Use "unassigned" folder for archives without a printer
  252. printer_folder = str(printer_id) if printer_id is not None else "unassigned"
  253. archive_dir = settings.archive_dir / printer_folder / archive_name
  254. archive_dir.mkdir(parents=True, exist_ok=True)
  255. # Copy 3MF file
  256. dest_file = archive_dir / source_file.name
  257. shutil.copy2(source_file, dest_file)
  258. # Parse 3MF metadata
  259. parser = ThreeMFParser(dest_file)
  260. metadata = parser.parse()
  261. # Save thumbnail if present
  262. thumbnail_path = None
  263. if "_thumbnail_data" in metadata:
  264. thumb_file = archive_dir / f"thumbnail{metadata['_thumbnail_ext']}"
  265. thumb_file.write_bytes(metadata["_thumbnail_data"])
  266. thumbnail_path = str(thumb_file.relative_to(settings.base_dir))
  267. del metadata["_thumbnail_data"]
  268. del metadata["_thumbnail_ext"]
  269. # Merge with print data from MQTT
  270. if print_data:
  271. metadata["_print_data"] = print_data
  272. # Determine status and timestamps
  273. status = print_data.get("status", "completed") if print_data else "archived"
  274. started_at = datetime.now() if status == "printing" else None
  275. completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
  276. # Create archive record
  277. archive = PrintArchive(
  278. printer_id=printer_id,
  279. filename=source_file.name,
  280. file_path=str(dest_file.relative_to(settings.base_dir)),
  281. file_size=dest_file.stat().st_size,
  282. thumbnail_path=thumbnail_path,
  283. print_name=metadata.get("print_name") or source_file.stem,
  284. print_time_seconds=metadata.get("print_time_seconds"),
  285. filament_used_grams=metadata.get("filament_used_grams"),
  286. filament_type=metadata.get("filament_type"),
  287. filament_color=metadata.get("filament_color"),
  288. layer_height=metadata.get("layer_height"),
  289. nozzle_diameter=metadata.get("nozzle_diameter"),
  290. bed_temperature=metadata.get("bed_temperature"),
  291. nozzle_temperature=metadata.get("nozzle_temperature"),
  292. makerworld_url=metadata.get("makerworld_url"),
  293. designer=metadata.get("designer"),
  294. status=status,
  295. started_at=started_at,
  296. completed_at=completed_at,
  297. extra_data=metadata,
  298. )
  299. self.db.add(archive)
  300. await self.db.commit()
  301. await self.db.refresh(archive)
  302. return archive
  303. async def get_archive(self, archive_id: int) -> PrintArchive | None:
  304. """Get an archive by ID."""
  305. result = await self.db.execute(
  306. select(PrintArchive).where(PrintArchive.id == archive_id)
  307. )
  308. return result.scalar_one_or_none()
  309. async def update_archive_status(
  310. self,
  311. archive_id: int,
  312. status: str,
  313. completed_at: datetime | None = None,
  314. ) -> bool:
  315. """Update the status of an archive."""
  316. archive = await self.get_archive(archive_id)
  317. if not archive:
  318. return False
  319. archive.status = status
  320. if completed_at:
  321. archive.completed_at = completed_at
  322. await self.db.commit()
  323. return True
  324. async def list_archives(
  325. self,
  326. printer_id: int | None = None,
  327. limit: int = 50,
  328. offset: int = 0,
  329. ) -> list[PrintArchive]:
  330. """List archives with optional filtering."""
  331. query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
  332. if printer_id:
  333. query = query.where(PrintArchive.printer_id == printer_id)
  334. query = query.limit(limit).offset(offset)
  335. result = await self.db.execute(query)
  336. return list(result.scalars().all())
  337. async def delete_archive(self, archive_id: int) -> bool:
  338. """Delete an archive and its files."""
  339. archive = await self.get_archive(archive_id)
  340. if not archive:
  341. return False
  342. # Delete files
  343. file_path = settings.base_dir / archive.file_path
  344. if file_path.exists():
  345. archive_dir = file_path.parent
  346. shutil.rmtree(archive_dir, ignore_errors=True)
  347. # Delete database record
  348. await self.db.delete(archive)
  349. await self.db.commit()
  350. return True
  351. async def attach_timelapse(
  352. self,
  353. archive_id: int,
  354. timelapse_data: bytes,
  355. filename: str = "timelapse.mp4",
  356. ) -> bool:
  357. """Attach a timelapse video to an archive."""
  358. archive = await self.get_archive(archive_id)
  359. if not archive:
  360. return False
  361. # Get archive directory
  362. file_path = settings.base_dir / archive.file_path
  363. archive_dir = file_path.parent
  364. # Save timelapse
  365. timelapse_file = archive_dir / filename
  366. timelapse_file.write_bytes(timelapse_data)
  367. # Update archive record
  368. archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
  369. await self.db.commit()
  370. return True