From beaa210466e88cd9c2e4802b1ac73d0a4f42eccd Mon Sep 17 00:00:00 2001 From: tcomlab Date: Wed, 15 Apr 2026 14:21:20 +0300 Subject: [PATCH] Sync Bambuddy thumbnails to InvenTree parts --- .env.example | 2 ++ README.md | 12 ++++++++++++ src/bambuddy_inventree_sync/bambuddy.py | 23 +++++++++++++++++++++++ src/bambuddy_inventree_sync/config.py | 2 ++ src/bambuddy_inventree_sync/inventree.py | 14 +++++++++++++- src/bambuddy_inventree_sync/sync.py | 22 ++++++++++++++++++++++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index a26d6a6..a46415f 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ WEBHOOK_SHARED_SECRET= # Sync behavior. SYNC_SUCCESS_ONLY=true +SYNC_PART_IMAGES=true +OVERWRITE_PART_IMAGES=false DEFAULT_STOCK_QUANTITY=1 INVENTREE_STOCK_STATUS=10 PART_IPN_PREFIX=BMB diff --git a/README.md b/README.md index ee99a2e..2c5014c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,18 @@ For each synced `Part`, the service updates these InvenTree parameters when matc - `Weight`: filament weight per printed item in grams. If a Bambuddy archive has `quantity=2`, the total filament weight is divided by 2. - `PrintTime`: print duration from Bambuddy. The visible value is formatted as `1h 6m 1s`; `data_numeric` stores the duration in seconds. +## InvenTree Part Images + +When `SYNC_PART_IMAGES=true`, the service downloads the Bambuddy archive thumbnail and uploads it to the InvenTree `Part.image` field. + +By default, existing InvenTree part images are preserved: + +```env +OVERWRITE_PART_IMAGES=false +``` + +Set `OVERWRITE_PART_IMAGES=true` if Bambuddy thumbnails should replace existing part images. + ## Useful Endpoints ```text diff --git a/src/bambuddy_inventree_sync/bambuddy.py b/src/bambuddy_inventree_sync/bambuddy.py index b8ed2dc..0f9b248 100644 --- a/src/bambuddy_inventree_sync/bambuddy.py +++ b/src/bambuddy_inventree_sync/bambuddy.py @@ -7,6 +7,13 @@ from .http_errors import ExternalApiError from .models import Archive +class DownloadedFile: + def __init__(self, *, content: bytes, content_type: str, filename: str) -> None: + self.content = content + self.content_type = content_type + self.filename = filename + + class BambuddyClient: def __init__(self, settings: Settings) -> None: base_url = settings.bambuddy_base_url.rstrip("/") @@ -31,6 +38,22 @@ class BambuddyClient: async def get_system_info(self) -> dict[str, Any]: return await self._request("GET", "/system/info") + async def get_archive_thumbnail(self, archive_id: int) -> DownloadedFile | None: + response = await self.client.get(f"/archives/{archive_id}/thumbnail") + if response.status_code == 404: + return None + if response.status_code >= 400: + body = response.text[:1000] + raise ExternalApiError(f"Bambuddy GET /archives/{archive_id}/thumbnail failed: HTTP {response.status_code}: {body}") + + content_type = response.headers.get("content-type", "image/png").split(";")[0] + extension = "jpg" if content_type == "image/jpeg" else "png" + return DownloadedFile( + content=response.content, + content_type=content_type, + filename=f"bambuddy-archive-{archive_id}-thumbnail.{extension}", + ) + async def list_archives( self, *, diff --git a/src/bambuddy_inventree_sync/config.py b/src/bambuddy_inventree_sync/config.py index 91912d8..a2820ae 100644 --- a/src/bambuddy_inventree_sync/config.py +++ b/src/bambuddy_inventree_sync/config.py @@ -21,6 +21,8 @@ class Settings(BaseSettings): webhook_shared_secret: str | None = None sync_success_only: bool = True + sync_part_images: bool = True + overwrite_part_images: bool = False default_stock_quantity: Annotated[float, Field(gt=0)] = 1 inventree_stock_status: int = 10 part_ipn_prefix: str = "BMB" diff --git a/src/bambuddy_inventree_sync/inventree.py b/src/bambuddy_inventree_sync/inventree.py index e9db2d1..dc6e40b 100644 --- a/src/bambuddy_inventree_sync/inventree.py +++ b/src/bambuddy_inventree_sync/inventree.py @@ -21,7 +21,6 @@ class InvenTreeClient: headers={ "Authorization": f"Token {settings.inventree_token}", "Accept": "application/json", - "Content-Type": "application/json", }, timeout=settings.http_timeout_seconds, trust_env=False, @@ -36,6 +35,9 @@ class InvenTreeClient: async def get_stock_location(self) -> dict[str, Any]: return await self._request("GET", f"/stock/location/{self.settings.inventree_stock_location_id}/") + async def get_part(self, part_id: int) -> dict[str, Any]: + return await self._request("GET", f"/part/{part_id}/") + async def find_part_by_ipn(self, ipn: str) -> dict[str, Any] | None: for params in ({"IPN": ipn, "limit": 100}, {"search": ipn, "limit": 100}): data = await self._request("GET", "/part/", params=params) @@ -79,6 +81,16 @@ class InvenTreeClient: } return await self._request("POST", "/stock/", json=payload) + async def upload_part_image(self, *, part_id: int, content: bytes, filename: str, content_type: str) -> dict[str, Any]: + response = await self.client.patch( + f"/part/{part_id}/", + files={"image": (filename, content, content_type)}, + ) + if response.status_code >= 400: + body = response.text[:1000] + raise ExternalApiError(f"InvenTree PATCH /part/{part_id}/ image failed: HTTP {response.status_code}: {body}") + return response.json() + async def upsert_part_parameter( self, *, diff --git a/src/bambuddy_inventree_sync/sync.py b/src/bambuddy_inventree_sync/sync.py index 86b8bc8..ee41e58 100644 --- a/src/bambuddy_inventree_sync/sync.py +++ b/src/bambuddy_inventree_sync/sync.py @@ -132,6 +132,7 @@ class ArchiveSyncService: part_id = await self._get_or_create_part(part_key=part_key, ipn=ipn, name=name, description=description) await self._sync_part_parameters(part_id=part_id, archive=archive) + await self._sync_part_image(part_id=part_id, archive=archive) batch = self.inventree.batch_for_archive(archive.id) existing_stock = await self.inventree.find_stock_by_batch(part_id=part_id, batch=batch) @@ -206,6 +207,27 @@ class ArchiveSyncService: for parameter in self.part_parameters_for_archive(archive): await self.inventree.upsert_part_parameter(part_id=part_id, **parameter) + async def _sync_part_image(self, *, part_id: int, archive: Archive) -> None: + if not self.settings.sync_part_images: + return + + if not self.settings.overwrite_part_images: + part = await self.inventree.get_part(part_id) + if part.get("image") or part.get("remote_image"): + return + + thumbnail = await self.bambuddy.get_archive_thumbnail(archive.id) + if not thumbnail: + return + + await self.inventree.upload_part_image( + part_id=part_id, + content=thumbnail.content, + filename=thumbnail.filename, + content_type=thumbnail.content_type, + ) + logger.info("Synced Bambuddy archive %s thumbnail to InvenTree part %s", archive.id, part_id) + def part_parameters_for_archive(self, archive: Archive) -> list[dict[str, str | float | None]]: parameters: list[dict[str, str | float | None]] = []