archive.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  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 ProjectPageParser:
  230. """Parser for extracting project page data from Bambu Lab 3MF files."""
  231. def __init__(self, file_path: Path):
  232. self.file_path = file_path
  233. def parse(self, archive_id: int) -> dict:
  234. """Extract project page metadata and images from 3MF file."""
  235. import html
  236. import re
  237. result = {
  238. "title": None,
  239. "description": None,
  240. "designer": None,
  241. "designer_user_id": None,
  242. "license": None,
  243. "copyright": None,
  244. "creation_date": None,
  245. "modification_date": None,
  246. "origin": None,
  247. "profile_title": None,
  248. "profile_description": None,
  249. "profile_cover": None,
  250. "profile_user_id": None,
  251. "profile_user_name": None,
  252. "design_model_id": None,
  253. "design_profile_id": None,
  254. "design_region": None,
  255. "model_pictures": [],
  256. "profile_pictures": [],
  257. "thumbnails": [],
  258. }
  259. try:
  260. with zipfile.ZipFile(self.file_path, "r") as zf:
  261. # Parse 3D/3dmodel.model for metadata
  262. model_path = "3D/3dmodel.model"
  263. if model_path in zf.namelist():
  264. content = zf.read(model_path).decode("utf-8", errors="ignore")
  265. # Extract metadata elements using regex
  266. # Format: <metadata name="Key">Value</metadata> or <metadata name="Key" />
  267. metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
  268. matches = re.findall(metadata_pattern, content)
  269. field_mapping = {
  270. "Title": "title",
  271. "Description": "description",
  272. "Designer": "designer",
  273. "DesignerUserId": "designer_user_id",
  274. "License": "license",
  275. "Copyright": "copyright",
  276. "CreationDate": "creation_date",
  277. "ModificationDate": "modification_date",
  278. "Origin": "origin",
  279. "ProfileTitle": "profile_title",
  280. "ProfileDescription": "profile_description",
  281. "ProfileCover": "profile_cover",
  282. "ProfileUserId": "profile_user_id",
  283. "ProfileUserName": "profile_user_name",
  284. "DesignModelId": "design_model_id",
  285. "DesignProfileId": "design_profile_id",
  286. "DesignRegion": "design_region",
  287. }
  288. for name, value in matches:
  289. if name in field_mapping:
  290. # Decode HTML entities multiple times (content is often triple-encoded)
  291. decoded = value.strip()
  292. prev = None
  293. while prev != decoded:
  294. prev = decoded
  295. decoded = html.unescape(decoded)
  296. # Normalize non-breaking spaces to regular spaces
  297. decoded = decoded.replace('\xa0', ' ')
  298. result[field_mapping[name]] = decoded if decoded else None
  299. # List images in Auxiliaries folder
  300. from urllib.parse import quote
  301. for name in zf.namelist():
  302. if name.startswith("Auxiliaries/Model Pictures/"):
  303. filename = name.split("/")[-1]
  304. if filename:
  305. result["model_pictures"].append({
  306. "name": filename,
  307. "path": name,
  308. "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
  309. })
  310. elif name.startswith("Auxiliaries/Profile Pictures/"):
  311. filename = name.split("/")[-1]
  312. if filename:
  313. result["profile_pictures"].append({
  314. "name": filename,
  315. "path": name,
  316. "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
  317. })
  318. elif name.startswith("Auxiliaries/.thumbnails/"):
  319. filename = name.split("/")[-1]
  320. if filename:
  321. result["thumbnails"].append({
  322. "name": filename,
  323. "path": name,
  324. "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
  325. })
  326. except Exception as e:
  327. result["_error"] = str(e)
  328. return result
  329. def get_image(self, image_path: str) -> tuple[bytes, str] | None:
  330. """Extract an image from the 3MF file.
  331. Returns tuple of (image_data, content_type) or None if not found.
  332. """
  333. try:
  334. with zipfile.ZipFile(self.file_path, "r") as zf:
  335. if image_path in zf.namelist():
  336. data = zf.read(image_path)
  337. # Determine content type from extension
  338. ext = image_path.lower().split(".")[-1]
  339. content_types = {
  340. "png": "image/png",
  341. "jpg": "image/jpeg",
  342. "jpeg": "image/jpeg",
  343. "webp": "image/webp",
  344. "gif": "image/gif",
  345. }
  346. content_type = content_types.get(ext, "application/octet-stream")
  347. return (data, content_type)
  348. except Exception:
  349. pass
  350. return None
  351. def update_metadata(self, updates: dict) -> bool:
  352. """Update project page metadata in the 3MF file.
  353. Args:
  354. updates: Dict with fields to update (title, description, designer, etc.)
  355. Returns:
  356. True if successful, False otherwise.
  357. """
  358. import html
  359. import re
  360. import tempfile
  361. try:
  362. # Read the 3MF file
  363. with zipfile.ZipFile(self.file_path, "r") as zf_read:
  364. # Find and read the 3dmodel.model file
  365. model_path = "3D/3dmodel.model"
  366. if model_path not in zf_read.namelist():
  367. return False
  368. content = zf_read.read(model_path).decode("utf-8")
  369. # Update metadata fields
  370. field_mapping = {
  371. "title": "Title",
  372. "description": "Description",
  373. "designer": "Designer",
  374. "license": "License",
  375. "copyright": "Copyright",
  376. "profile_title": "ProfileTitle",
  377. "profile_description": "ProfileDescription",
  378. }
  379. for field, xml_name in field_mapping.items():
  380. if field in updates and updates[field] is not None:
  381. new_value = html.escape(updates[field])
  382. # Replace existing metadata or we'd need to add it
  383. pattern = rf'(<metadata\s+name="{xml_name}"[^>]*>)[^<]*(</metadata>)'
  384. replacement = rf'\g<1>{new_value}\g<2>'
  385. content = re.sub(pattern, replacement, content)
  386. # Write to a temporary file first
  387. with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
  388. tmp_path = Path(tmp.name)
  389. # Create new zip with updated content
  390. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
  391. for item in zf_read.namelist():
  392. if item == model_path:
  393. zf_write.writestr(item, content.encode("utf-8"))
  394. else:
  395. zf_write.writestr(item, zf_read.read(item))
  396. # Replace original file with updated one
  397. shutil.move(tmp_path, self.file_path)
  398. return True
  399. except Exception:
  400. # Clean up temp file if it exists
  401. if "tmp_path" in locals() and tmp_path.exists():
  402. tmp_path.unlink()
  403. return False
  404. class ArchiveService:
  405. """Service for archiving print jobs."""
  406. def __init__(self, db: AsyncSession):
  407. self.db = db
  408. async def archive_print(
  409. self,
  410. printer_id: int | None,
  411. source_file: Path,
  412. print_data: dict | None = None,
  413. ) -> PrintArchive | None:
  414. """Archive a 3MF file with metadata."""
  415. # Verify printer exists if specified
  416. if printer_id is not None:
  417. result = await self.db.execute(
  418. select(Printer).where(Printer.id == printer_id)
  419. )
  420. printer = result.scalar_one_or_none()
  421. if not printer:
  422. return None
  423. # Create archive directory structure
  424. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  425. archive_name = f"{timestamp}_{source_file.stem}"
  426. # Use "unassigned" folder for archives without a printer
  427. printer_folder = str(printer_id) if printer_id is not None else "unassigned"
  428. archive_dir = settings.archive_dir / printer_folder / archive_name
  429. archive_dir.mkdir(parents=True, exist_ok=True)
  430. # Copy 3MF file
  431. dest_file = archive_dir / source_file.name
  432. shutil.copy2(source_file, dest_file)
  433. # Parse 3MF metadata
  434. parser = ThreeMFParser(dest_file)
  435. metadata = parser.parse()
  436. # Save thumbnail if present
  437. thumbnail_path = None
  438. if "_thumbnail_data" in metadata:
  439. thumb_file = archive_dir / f"thumbnail{metadata['_thumbnail_ext']}"
  440. thumb_file.write_bytes(metadata["_thumbnail_data"])
  441. thumbnail_path = str(thumb_file.relative_to(settings.base_dir))
  442. del metadata["_thumbnail_data"]
  443. del metadata["_thumbnail_ext"]
  444. # Merge with print data from MQTT
  445. if print_data:
  446. metadata["_print_data"] = print_data
  447. # Determine status and timestamps
  448. status = print_data.get("status", "completed") if print_data else "archived"
  449. started_at = datetime.now() if status == "printing" else None
  450. completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
  451. # Create archive record
  452. archive = PrintArchive(
  453. printer_id=printer_id,
  454. filename=source_file.name,
  455. file_path=str(dest_file.relative_to(settings.base_dir)),
  456. file_size=dest_file.stat().st_size,
  457. thumbnail_path=thumbnail_path,
  458. print_name=metadata.get("print_name") or source_file.stem,
  459. print_time_seconds=metadata.get("print_time_seconds"),
  460. filament_used_grams=metadata.get("filament_used_grams"),
  461. filament_type=metadata.get("filament_type"),
  462. filament_color=metadata.get("filament_color"),
  463. layer_height=metadata.get("layer_height"),
  464. nozzle_diameter=metadata.get("nozzle_diameter"),
  465. bed_temperature=metadata.get("bed_temperature"),
  466. nozzle_temperature=metadata.get("nozzle_temperature"),
  467. makerworld_url=metadata.get("makerworld_url"),
  468. designer=metadata.get("designer"),
  469. status=status,
  470. started_at=started_at,
  471. completed_at=completed_at,
  472. extra_data=metadata,
  473. )
  474. self.db.add(archive)
  475. await self.db.commit()
  476. await self.db.refresh(archive)
  477. return archive
  478. async def get_archive(self, archive_id: int) -> PrintArchive | None:
  479. """Get an archive by ID."""
  480. result = await self.db.execute(
  481. select(PrintArchive).where(PrintArchive.id == archive_id)
  482. )
  483. return result.scalar_one_or_none()
  484. async def update_archive_status(
  485. self,
  486. archive_id: int,
  487. status: str,
  488. completed_at: datetime | None = None,
  489. ) -> bool:
  490. """Update the status of an archive."""
  491. archive = await self.get_archive(archive_id)
  492. if not archive:
  493. return False
  494. archive.status = status
  495. if completed_at:
  496. archive.completed_at = completed_at
  497. await self.db.commit()
  498. return True
  499. async def list_archives(
  500. self,
  501. printer_id: int | None = None,
  502. limit: int = 50,
  503. offset: int = 0,
  504. ) -> list[PrintArchive]:
  505. """List archives with optional filtering."""
  506. query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
  507. if printer_id:
  508. query = query.where(PrintArchive.printer_id == printer_id)
  509. query = query.limit(limit).offset(offset)
  510. result = await self.db.execute(query)
  511. return list(result.scalars().all())
  512. async def delete_archive(self, archive_id: int) -> bool:
  513. """Delete an archive and its files."""
  514. archive = await self.get_archive(archive_id)
  515. if not archive:
  516. return False
  517. # Delete files
  518. file_path = settings.base_dir / archive.file_path
  519. if file_path.exists():
  520. archive_dir = file_path.parent
  521. shutil.rmtree(archive_dir, ignore_errors=True)
  522. # Delete database record
  523. await self.db.delete(archive)
  524. await self.db.commit()
  525. return True
  526. async def attach_timelapse(
  527. self,
  528. archive_id: int,
  529. timelapse_data: bytes,
  530. filename: str = "timelapse.mp4",
  531. ) -> bool:
  532. """Attach a timelapse video to an archive."""
  533. archive = await self.get_archive(archive_id)
  534. if not archive:
  535. return False
  536. # Get archive directory
  537. file_path = settings.base_dir / archive.file_path
  538. archive_dir = file_path.parent
  539. # Save timelapse
  540. timelapse_file = archive_dir / filename
  541. timelapse_file.write_bytes(timelapse_data)
  542. # Update archive record
  543. archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
  544. await self.db.commit()
  545. return True