archive.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  1. import hashlib
  2. import json
  3. import zipfile
  4. import shutil
  5. from datetime import datetime
  6. from pathlib import Path
  7. from xml.etree import ElementTree as ET
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy import select, and_, or_
  10. from backend.app.core.config import settings
  11. from backend.app.models.archive import PrintArchive
  12. from backend.app.models.printer import Printer
  13. from backend.app.models.filament import Filament
  14. class ThreeMFParser:
  15. """Parser for Bambu Lab 3MF files."""
  16. def __init__(self, file_path: Path):
  17. self.file_path = file_path
  18. self.metadata: dict = {}
  19. def parse(self) -> dict:
  20. """Extract metadata from 3MF file."""
  21. try:
  22. with zipfile.ZipFile(self.file_path, "r") as zf:
  23. self._parse_slice_info(zf)
  24. self._parse_project_settings(zf)
  25. self._parse_3dmodel(zf)
  26. self._extract_thumbnail(zf)
  27. # Prefer slice_info for colors (shows ALL filaments actually used in print)
  28. # project_settings may filter out "support" filaments incorrectly
  29. if self.metadata.get("_slice_filament_color"):
  30. self.metadata["filament_color"] = self.metadata["_slice_filament_color"]
  31. if not self.metadata.get("filament_type") and self.metadata.get("_slice_filament_type"):
  32. self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
  33. # Clean up internal keys
  34. self.metadata.pop("_slice_filament_type", None)
  35. self.metadata.pop("_slice_filament_color", None)
  36. except Exception:
  37. pass
  38. return self.metadata
  39. def _parse_slice_info(self, zf: zipfile.ZipFile):
  40. """Parse slice_info.config for print settings."""
  41. try:
  42. if "Metadata/slice_info.config" in zf.namelist():
  43. content = zf.read("Metadata/slice_info.config").decode()
  44. root = ET.fromstring(content)
  45. # Get first plate's metadata
  46. plate = root.find(".//plate")
  47. if plate is not None:
  48. # Get prediction and weight from metadata elements
  49. for meta in plate.findall("metadata"):
  50. key = meta.get("key")
  51. value = meta.get("value")
  52. if key == "prediction" and value:
  53. self.metadata["print_time_seconds"] = int(value)
  54. elif key == "weight" and value:
  55. self.metadata["filament_used_grams"] = float(value)
  56. # Get filament info from ALL filaments actually used in the print
  57. # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
  58. filaments = root.findall(".//filament")
  59. if filaments:
  60. # Collect all unique filament types and colors
  61. types = []
  62. colors = []
  63. for f in filaments:
  64. ftype = f.get("type")
  65. fcolor = f.get("color")
  66. if ftype and ftype not in types:
  67. types.append(ftype)
  68. if fcolor and fcolor not in colors:
  69. colors.append(fcolor)
  70. if types:
  71. self.metadata["_slice_filament_type"] = ", ".join(types)
  72. if colors:
  73. self.metadata["_slice_filament_color"] = ",".join(colors)
  74. except Exception:
  75. pass
  76. def _parse_project_settings(self, zf: zipfile.ZipFile):
  77. """Parse project settings for print configuration."""
  78. try:
  79. if "Metadata/project_settings.config" in zf.namelist():
  80. content = zf.read("Metadata/project_settings.config").decode()
  81. try:
  82. data = json.loads(content)
  83. self._extract_filament_info(data)
  84. self._extract_print_settings(data)
  85. except json.JSONDecodeError:
  86. pass
  87. except Exception:
  88. pass
  89. def _extract_filament_info(self, data: dict):
  90. """Extract filament info, preferring non-support filaments."""
  91. try:
  92. filament_types = data.get("filament_type", [])
  93. filament_colors = data.get("filament_colour", [])
  94. filament_is_support = data.get("filament_is_support", [])
  95. if not filament_types:
  96. return
  97. # Collect all non-support filaments
  98. non_support_types = []
  99. non_support_colors = []
  100. for i, ftype in enumerate(filament_types):
  101. is_support = filament_is_support[i] if i < len(filament_is_support) else '0'
  102. if is_support == '0':
  103. if ftype and ftype not in non_support_types:
  104. non_support_types.append(ftype)
  105. if i < len(filament_colors) and filament_colors[i]:
  106. color = filament_colors[i]
  107. if color not in non_support_colors:
  108. non_support_colors.append(color)
  109. # Fallback to first filament if all are support
  110. if not non_support_types and filament_types:
  111. non_support_types = [filament_types[0]]
  112. if not non_support_colors and filament_colors:
  113. non_support_colors = [filament_colors[0]]
  114. # Store filament type(s)
  115. if non_support_types:
  116. self.metadata["filament_type"] = ", ".join(non_support_types)
  117. # Store all colors as comma-separated (for multi-color display)
  118. if non_support_colors:
  119. self.metadata["filament_color"] = ",".join(non_support_colors)
  120. except Exception:
  121. pass
  122. def _extract_print_settings(self, data: dict):
  123. """Extract print settings from JSON config."""
  124. try:
  125. # Layer height - usually an array, get first value
  126. if "layer_height" in data:
  127. val = data["layer_height"]
  128. if isinstance(val, list) and val:
  129. self.metadata["layer_height"] = float(val[0])
  130. elif isinstance(val, (int, float, str)):
  131. self.metadata["layer_height"] = float(val)
  132. # Nozzle diameter
  133. if "nozzle_diameter" in data:
  134. val = data["nozzle_diameter"]
  135. if isinstance(val, list) and val:
  136. self.metadata["nozzle_diameter"] = float(val[0])
  137. elif isinstance(val, (int, float, str)):
  138. self.metadata["nozzle_diameter"] = float(val)
  139. # Bed temperature - first layer or regular
  140. for key in ["bed_temperature_initial_layer", "bed_temperature"]:
  141. if key in data:
  142. val = data[key]
  143. if isinstance(val, list) and val:
  144. self.metadata["bed_temperature"] = int(float(val[0]))
  145. elif isinstance(val, (int, float, str)):
  146. self.metadata["bed_temperature"] = int(float(val))
  147. break
  148. # Nozzle temperature
  149. for key in ["nozzle_temperature_initial_layer", "nozzle_temperature"]:
  150. if key in data:
  151. val = data[key]
  152. if isinstance(val, list) and val:
  153. self.metadata["nozzle_temperature"] = int(float(val[0]))
  154. elif isinstance(val, (int, float, str)):
  155. self.metadata["nozzle_temperature"] = int(float(val))
  156. break
  157. except Exception:
  158. pass
  159. def _extract_settings_from_content(self, content: str):
  160. """Extract print settings from config content."""
  161. settings_map = {
  162. "layer_height": ("layer_height", float),
  163. "nozzle_diameter": ("nozzle_diameter", float),
  164. "bed_temperature": ("bed_temperature", int),
  165. "nozzle_temperature": ("nozzle_temperature", int),
  166. }
  167. for key, (search_key, converter) in settings_map.items():
  168. if key not in self.metadata:
  169. try:
  170. # Try JSON format
  171. if f'"{search_key}"' in content:
  172. start = content.find(f'"{search_key}"')
  173. value_start = content.find(":", start) + 1
  174. value_end = content.find(",", value_start)
  175. if value_end == -1:
  176. value_end = content.find("}", value_start)
  177. value = content[value_start:value_end].strip().strip('"')
  178. self.metadata[key] = converter(value)
  179. except Exception:
  180. pass
  181. def _parse_3dmodel(self, zf: zipfile.ZipFile):
  182. """Parse 3D/3dmodel.model for MakerWorld metadata."""
  183. import re
  184. try:
  185. model_path = "3D/3dmodel.model"
  186. if model_path not in zf.namelist():
  187. return
  188. content = zf.read(model_path).decode("utf-8", errors="ignore")
  189. # Parse XML metadata elements
  190. # MakerWorld adds metadata like: <metadata name="Designer">username</metadata>
  191. metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
  192. matches = re.findall(metadata_pattern, content)
  193. makerworld_fields = {}
  194. for name, value in matches:
  195. makerworld_fields[name] = value.strip()
  196. # Check for direct MakerWorld URL in content
  197. url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)'
  198. url_match = re.search(url_pattern, content)
  199. if url_match:
  200. self.metadata["makerworld_url"] = url_match.group(0)
  201. self.metadata["makerworld_model_id"] = url_match.group(1)
  202. # Extract model ID from DSM reference in image URLs
  203. # Format: https://makerworld.bblmw.com/makerworld/model/DSM00000001275614/...
  204. # The numeric part (1275614) is the MakerWorld model ID
  205. if "makerworld_url" not in self.metadata:
  206. dsm_pattern = r'DSM0+(\d+)'
  207. dsm_match = re.search(dsm_pattern, content)
  208. if dsm_match:
  209. model_id = dsm_match.group(1)
  210. self.metadata["makerworld_url"] = f"https://makerworld.com/en/models/{model_id}"
  211. self.metadata["makerworld_model_id"] = model_id
  212. # Store designer info
  213. if "Designer" in makerworld_fields:
  214. self.metadata["designer"] = makerworld_fields["Designer"]
  215. if "Title" in makerworld_fields:
  216. self.metadata["print_name"] = makerworld_fields["Title"]
  217. except Exception:
  218. pass
  219. def _extract_thumbnail(self, zf: zipfile.ZipFile):
  220. """Extract thumbnail image from 3MF."""
  221. thumbnail_paths = [
  222. "Metadata/plate_1.png",
  223. "Metadata/thumbnail.png",
  224. "Metadata/model_thumbnail.png",
  225. ]
  226. for thumb_path in thumbnail_paths:
  227. if thumb_path in zf.namelist():
  228. self.metadata["_thumbnail_data"] = zf.read(thumb_path)
  229. self.metadata["_thumbnail_ext"] = ".png"
  230. break
  231. class ProjectPageParser:
  232. """Parser for extracting project page data from Bambu Lab 3MF files."""
  233. def __init__(self, file_path: Path):
  234. self.file_path = file_path
  235. def parse(self, archive_id: int) -> dict:
  236. """Extract project page metadata and images from 3MF file."""
  237. import html
  238. import re
  239. result = {
  240. "title": None,
  241. "description": None,
  242. "designer": None,
  243. "designer_user_id": None,
  244. "license": None,
  245. "copyright": None,
  246. "creation_date": None,
  247. "modification_date": None,
  248. "origin": None,
  249. "profile_title": None,
  250. "profile_description": None,
  251. "profile_cover": None,
  252. "profile_user_id": None,
  253. "profile_user_name": None,
  254. "design_model_id": None,
  255. "design_profile_id": None,
  256. "design_region": None,
  257. "model_pictures": [],
  258. "profile_pictures": [],
  259. "thumbnails": [],
  260. }
  261. try:
  262. with zipfile.ZipFile(self.file_path, "r") as zf:
  263. # Parse 3D/3dmodel.model for metadata
  264. model_path = "3D/3dmodel.model"
  265. if model_path in zf.namelist():
  266. content = zf.read(model_path).decode("utf-8", errors="ignore")
  267. # Extract metadata elements using regex
  268. # Format: <metadata name="Key">Value</metadata> or <metadata name="Key" />
  269. metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
  270. matches = re.findall(metadata_pattern, content)
  271. field_mapping = {
  272. "Title": "title",
  273. "Description": "description",
  274. "Designer": "designer",
  275. "DesignerUserId": "designer_user_id",
  276. "License": "license",
  277. "Copyright": "copyright",
  278. "CreationDate": "creation_date",
  279. "ModificationDate": "modification_date",
  280. "Origin": "origin",
  281. "ProfileTitle": "profile_title",
  282. "ProfileDescription": "profile_description",
  283. "ProfileCover": "profile_cover",
  284. "ProfileUserId": "profile_user_id",
  285. "ProfileUserName": "profile_user_name",
  286. "DesignModelId": "design_model_id",
  287. "DesignProfileId": "design_profile_id",
  288. "DesignRegion": "design_region",
  289. }
  290. for name, value in matches:
  291. if name in field_mapping:
  292. # Decode HTML entities multiple times (content is often triple-encoded)
  293. decoded = value.strip()
  294. prev = None
  295. while prev != decoded:
  296. prev = decoded
  297. decoded = html.unescape(decoded)
  298. # Normalize non-breaking spaces to regular spaces
  299. decoded = decoded.replace('\xa0', ' ')
  300. result[field_mapping[name]] = decoded if decoded else None
  301. # List images in Auxiliaries folder
  302. from urllib.parse import quote
  303. for name in zf.namelist():
  304. if name.startswith("Auxiliaries/Model Pictures/"):
  305. filename = name.split("/")[-1]
  306. if filename:
  307. result["model_pictures"].append({
  308. "name": filename,
  309. "path": name,
  310. "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
  311. })
  312. elif name.startswith("Auxiliaries/Profile Pictures/"):
  313. filename = name.split("/")[-1]
  314. if filename:
  315. result["profile_pictures"].append({
  316. "name": filename,
  317. "path": name,
  318. "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
  319. })
  320. elif name.startswith("Auxiliaries/.thumbnails/"):
  321. filename = name.split("/")[-1]
  322. if filename:
  323. result["thumbnails"].append({
  324. "name": filename,
  325. "path": name,
  326. "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
  327. })
  328. except Exception as e:
  329. result["_error"] = str(e)
  330. return result
  331. def get_image(self, image_path: str) -> tuple[bytes, str] | None:
  332. """Extract an image from the 3MF file.
  333. Returns tuple of (image_data, content_type) or None if not found.
  334. """
  335. try:
  336. with zipfile.ZipFile(self.file_path, "r") as zf:
  337. if image_path in zf.namelist():
  338. data = zf.read(image_path)
  339. # Determine content type from extension
  340. ext = image_path.lower().split(".")[-1]
  341. content_types = {
  342. "png": "image/png",
  343. "jpg": "image/jpeg",
  344. "jpeg": "image/jpeg",
  345. "webp": "image/webp",
  346. "gif": "image/gif",
  347. }
  348. content_type = content_types.get(ext, "application/octet-stream")
  349. return (data, content_type)
  350. except Exception:
  351. pass
  352. return None
  353. def update_metadata(self, updates: dict) -> bool:
  354. """Update project page metadata in the 3MF file.
  355. Args:
  356. updates: Dict with fields to update (title, description, designer, etc.)
  357. Returns:
  358. True if successful, False otherwise.
  359. """
  360. import html
  361. import re
  362. import tempfile
  363. try:
  364. # Read the 3MF file
  365. with zipfile.ZipFile(self.file_path, "r") as zf_read:
  366. # Find and read the 3dmodel.model file
  367. model_path = "3D/3dmodel.model"
  368. if model_path not in zf_read.namelist():
  369. return False
  370. content = zf_read.read(model_path).decode("utf-8")
  371. # Update metadata fields
  372. field_mapping = {
  373. "title": "Title",
  374. "description": "Description",
  375. "designer": "Designer",
  376. "license": "License",
  377. "copyright": "Copyright",
  378. "profile_title": "ProfileTitle",
  379. "profile_description": "ProfileDescription",
  380. }
  381. for field, xml_name in field_mapping.items():
  382. if field in updates and updates[field] is not None:
  383. new_value = html.escape(updates[field])
  384. # Replace existing metadata or we'd need to add it
  385. pattern = rf'(<metadata\s+name="{xml_name}"[^>]*>)[^<]*(</metadata>)'
  386. replacement = rf'\g<1>{new_value}\g<2>'
  387. content = re.sub(pattern, replacement, content)
  388. # Write to a temporary file first
  389. with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
  390. tmp_path = Path(tmp.name)
  391. # Create new zip with updated content
  392. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
  393. for item in zf_read.namelist():
  394. if item == model_path:
  395. zf_write.writestr(item, content.encode("utf-8"))
  396. else:
  397. zf_write.writestr(item, zf_read.read(item))
  398. # Replace original file with updated one
  399. shutil.move(tmp_path, self.file_path)
  400. return True
  401. except Exception:
  402. # Clean up temp file if it exists
  403. if "tmp_path" in locals() and tmp_path.exists():
  404. tmp_path.unlink()
  405. return False
  406. class ArchiveService:
  407. """Service for archiving print jobs."""
  408. def __init__(self, db: AsyncSession):
  409. self.db = db
  410. @staticmethod
  411. def compute_file_hash(file_path: Path) -> str:
  412. """Compute SHA256 hash of a file for duplicate detection."""
  413. sha256 = hashlib.sha256()
  414. with open(file_path, "rb") as f:
  415. # Read in chunks to handle large files
  416. for chunk in iter(lambda: f.read(8192), b""):
  417. sha256.update(chunk)
  418. return sha256.hexdigest()
  419. async def get_duplicate_hashes(self) -> set[str]:
  420. """Get all content hashes that appear more than once.
  421. Returns a set of hashes that have duplicates.
  422. """
  423. from sqlalchemy import func
  424. result = await self.db.execute(
  425. select(PrintArchive.content_hash)
  426. .where(PrintArchive.content_hash.isnot(None))
  427. .group_by(PrintArchive.content_hash)
  428. .having(func.count(PrintArchive.id) > 1)
  429. )
  430. return {row[0] for row in result.all()}
  431. async def find_duplicates(
  432. self,
  433. archive_id: int,
  434. content_hash: str | None = None,
  435. print_name: str | None = None,
  436. makerworld_model_id: str | None = None,
  437. ) -> list[dict]:
  438. """Find duplicate archives based on hash or name matching.
  439. Returns list of dicts with id, print_name, created_at, match_type.
  440. """
  441. duplicates = []
  442. # First, find exact matches by content hash
  443. if content_hash:
  444. result = await self.db.execute(
  445. select(PrintArchive)
  446. .where(
  447. and_(
  448. PrintArchive.content_hash == content_hash,
  449. PrintArchive.id != archive_id,
  450. )
  451. )
  452. .order_by(PrintArchive.created_at.desc())
  453. .limit(10)
  454. )
  455. for archive in result.scalars().all():
  456. duplicates.append({
  457. "id": archive.id,
  458. "print_name": archive.print_name,
  459. "created_at": archive.created_at,
  460. "match_type": "exact",
  461. })
  462. # Then, find similar matches by print name or MakerWorld ID
  463. if print_name or makerworld_model_id:
  464. conditions = [PrintArchive.id != archive_id]
  465. name_conditions = []
  466. if print_name:
  467. # Match if print names are similar (ignoring case)
  468. name_conditions.append(PrintArchive.print_name.ilike(print_name))
  469. if makerworld_model_id:
  470. # Match by MakerWorld model ID stored in extra_data
  471. name_conditions.append(
  472. PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id
  473. )
  474. if name_conditions:
  475. conditions.append(or_(*name_conditions))
  476. result = await self.db.execute(
  477. select(PrintArchive)
  478. .where(and_(*conditions))
  479. .order_by(PrintArchive.created_at.desc())
  480. .limit(10)
  481. )
  482. for archive in result.scalars().all():
  483. # Don't add if already in duplicates (exact match)
  484. if not any(d["id"] == archive.id for d in duplicates):
  485. duplicates.append({
  486. "id": archive.id,
  487. "print_name": archive.print_name,
  488. "created_at": archive.created_at,
  489. "match_type": "similar",
  490. })
  491. return duplicates
  492. async def archive_print(
  493. self,
  494. printer_id: int | None,
  495. source_file: Path,
  496. print_data: dict | None = None,
  497. ) -> PrintArchive | None:
  498. """Archive a 3MF file with metadata."""
  499. # Verify printer exists if specified
  500. if printer_id is not None:
  501. result = await self.db.execute(
  502. select(Printer).where(Printer.id == printer_id)
  503. )
  504. printer = result.scalar_one_or_none()
  505. if not printer:
  506. return None
  507. # Create archive directory structure
  508. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  509. archive_name = f"{timestamp}_{source_file.stem}"
  510. # Use "unassigned" folder for archives without a printer
  511. printer_folder = str(printer_id) if printer_id is not None else "unassigned"
  512. archive_dir = settings.archive_dir / printer_folder / archive_name
  513. archive_dir.mkdir(parents=True, exist_ok=True)
  514. # Copy 3MF file
  515. dest_file = archive_dir / source_file.name
  516. shutil.copy2(source_file, dest_file)
  517. # Compute content hash for duplicate detection
  518. content_hash = self.compute_file_hash(dest_file)
  519. # Parse 3MF metadata
  520. parser = ThreeMFParser(dest_file)
  521. metadata = parser.parse()
  522. # Save thumbnail if present
  523. thumbnail_path = None
  524. if "_thumbnail_data" in metadata:
  525. thumb_file = archive_dir / f"thumbnail{metadata['_thumbnail_ext']}"
  526. thumb_file.write_bytes(metadata["_thumbnail_data"])
  527. thumbnail_path = str(thumb_file.relative_to(settings.base_dir))
  528. del metadata["_thumbnail_data"]
  529. del metadata["_thumbnail_ext"]
  530. # Merge with print data from MQTT
  531. if print_data:
  532. metadata["_print_data"] = print_data
  533. # Determine status and timestamps
  534. status = print_data.get("status", "completed") if print_data else "archived"
  535. started_at = datetime.now() if status == "printing" else None
  536. completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
  537. # Calculate cost based on filament usage and type
  538. cost = None
  539. filament_grams = metadata.get("filament_used_grams")
  540. filament_type = metadata.get("filament_type")
  541. if filament_grams and filament_type:
  542. # For multi-material prints, use the first filament type for cost calculation
  543. primary_type = filament_type.split(",")[0].strip()
  544. # Look up filament cost_per_kg from database
  545. filament_result = await self.db.execute(
  546. select(Filament).where(Filament.type == primary_type).limit(1)
  547. )
  548. filament = filament_result.scalar_one_or_none()
  549. if filament:
  550. cost = round((filament_grams / 1000) * filament.cost_per_kg, 2)
  551. else:
  552. # Default cost_per_kg if filament type not found
  553. default_cost_per_kg = 25.0
  554. cost = round((filament_grams / 1000) * default_cost_per_kg, 2)
  555. # Create archive record
  556. archive = PrintArchive(
  557. printer_id=printer_id,
  558. filename=source_file.name,
  559. file_path=str(dest_file.relative_to(settings.base_dir)),
  560. file_size=dest_file.stat().st_size,
  561. content_hash=content_hash,
  562. thumbnail_path=thumbnail_path,
  563. print_name=metadata.get("print_name") or source_file.stem,
  564. print_time_seconds=metadata.get("print_time_seconds"),
  565. filament_used_grams=metadata.get("filament_used_grams"),
  566. filament_type=metadata.get("filament_type"),
  567. filament_color=metadata.get("filament_color"),
  568. layer_height=metadata.get("layer_height"),
  569. nozzle_diameter=metadata.get("nozzle_diameter"),
  570. bed_temperature=metadata.get("bed_temperature"),
  571. nozzle_temperature=metadata.get("nozzle_temperature"),
  572. makerworld_url=metadata.get("makerworld_url"),
  573. designer=metadata.get("designer"),
  574. status=status,
  575. started_at=started_at,
  576. completed_at=completed_at,
  577. cost=cost,
  578. extra_data=metadata,
  579. )
  580. self.db.add(archive)
  581. await self.db.commit()
  582. await self.db.refresh(archive)
  583. return archive
  584. async def get_archive(self, archive_id: int) -> PrintArchive | None:
  585. """Get an archive by ID."""
  586. result = await self.db.execute(
  587. select(PrintArchive).where(PrintArchive.id == archive_id)
  588. )
  589. return result.scalar_one_or_none()
  590. async def update_archive_status(
  591. self,
  592. archive_id: int,
  593. status: str,
  594. completed_at: datetime | None = None,
  595. ) -> bool:
  596. """Update the status of an archive."""
  597. archive = await self.get_archive(archive_id)
  598. if not archive:
  599. return False
  600. archive.status = status
  601. if completed_at:
  602. archive.completed_at = completed_at
  603. await self.db.commit()
  604. return True
  605. async def list_archives(
  606. self,
  607. printer_id: int | None = None,
  608. limit: int = 50,
  609. offset: int = 0,
  610. ) -> list[PrintArchive]:
  611. """List archives with optional filtering."""
  612. query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
  613. if printer_id:
  614. query = query.where(PrintArchive.printer_id == printer_id)
  615. query = query.limit(limit).offset(offset)
  616. result = await self.db.execute(query)
  617. return list(result.scalars().all())
  618. async def delete_archive(self, archive_id: int) -> bool:
  619. """Delete an archive and its files."""
  620. archive = await self.get_archive(archive_id)
  621. if not archive:
  622. return False
  623. # Delete files
  624. file_path = settings.base_dir / archive.file_path
  625. if file_path.exists():
  626. archive_dir = file_path.parent
  627. shutil.rmtree(archive_dir, ignore_errors=True)
  628. # Delete database record
  629. await self.db.delete(archive)
  630. await self.db.commit()
  631. return True
  632. async def attach_timelapse(
  633. self,
  634. archive_id: int,
  635. timelapse_data: bytes,
  636. filename: str = "timelapse.mp4",
  637. ) -> bool:
  638. """Attach a timelapse video to an archive."""
  639. archive = await self.get_archive(archive_id)
  640. if not archive:
  641. return False
  642. # Get archive directory
  643. file_path = settings.base_dir / archive.file_path
  644. archive_dir = file_path.parent
  645. # Save timelapse
  646. timelapse_file = archive_dir / filename
  647. timelapse_file.write_bytes(timelapse_data)
  648. # Update archive record
  649. archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
  650. await self.db.commit()
  651. return True