Browse Source

Fix critical FTP upload failure and revert dangerous exception narrowing

The CodeQL cleanup in "Housekeeping" (2b11efd) bulk-narrowed except
clauses across 50+ files, breaking FTP uploads on ALL printer models.
ftplib.error_perm (550 errors) is not a subclass of ftplib.error_reply,
so diagnose_storage() CWD failures escaped the handler and prevented
STOR from ever executing — causing 100% upload failure and HTTP 500s
on /api/v1/archives/{id}/reprint and /api/v1/library/files/{id}/print.

FTP fixes:
- Remove diagnose_storage() from upload hot path
- Change all except (OSError, ftplib.error_reply) to
  except (OSError, ftplib.Error) across bambu_ftp.py

Exception handling reverts (9 files):
- Revert narrowed except clauses back to except Exception in route
  handlers and service code where broad catches are intentional
  defensive programming (archive parsing, HTTP clients, 3MF/ZIP
  processing, Home Assistant, firmware checks)
- Keep narrow exceptions only where safe (single-op blocks like
  int(), file.unlink(), socket.close())
- Remove unused XMLParseError imports from archive.py, threemf_tools.py

Closes #287
maziggy 3 tháng trước cách đây
mục cha
commit
4b46e443dc

+ 113 - 0
DOCKERHUB.md

@@ -0,0 +1,113 @@
+# Bambuddy
+
+**Self-hosted print archive and management system for Bambu Lab 3D printers.**
+
+No cloud dependency. Complete privacy. Full control.
+
+[![GitHub](https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square&label=GitHub)](https://github.com/maziggy/bambuddy)
+[![License](https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square)](https://github.com/maziggy/bambuddy/blob/main/LICENSE)
+[![Discord](https://img.shields.io/discord/1461241694715645994?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/aFS3ZfScHM)
+
+## Quick Start
+
+```bash
+mkdir bambuddy && cd bambuddy
+curl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
+docker compose up -d
+```
+
+Open **http://localhost:8000** and add your printer.
+
+> **Requirements:** Bambu Lab printer with Developer Mode enabled, on the same local network.
+
+## Supported Architectures
+
+| Architecture | Tag |
+|---|---|
+| x86-64 (Intel/AMD) | `amd64` |
+| arm64 (Raspberry Pi 4/5) | `arm64` |
+
+## Features
+
+- **Real-Time Monitoring** — Live printer status, camera streaming, HMS error tracking (853 codes translated), resizable multi-printer dashboard
+- **Print Archive** — Automatic 3MF archiving with metadata, interactive 3D model viewer (Three.js), photo attachments, failure analysis, side-by-side comparison
+- **Print Scheduling** — Drag-and-drop queue, multi-printer assignment by model or location, time-based scheduling, re-print with AMS mapping
+- **Smart Automation** — Smart plug control (Tasmota, Home Assistant, MQTT), auto power-on/off, energy monitoring, maintenance reminders
+- **Proxy Mode** — Print remotely from Bambu Studio/OrcaSlicer without VPN or port forwarding, end-to-end TLS encrypted
+- **Notifications** — WhatsApp, Telegram, Discord, Email, Pushover, ntfy with customizable templates and quiet hours
+- **Projects** — Group related prints, track parts and plates, bill of materials, cost tracking, export as ZIP/JSON
+- **File Manager** — Upload and organize sliced files, folder structure, print directly to any printer
+- **Integrations** — Spoolman filament sync, MQTT publishing, Prometheus metrics, Bambu Cloud profiles, REST API, Home Assistant
+- **Virtual Printer** — Appears in your slicer via SSDP discovery, multiple operating modes (archive, review, queue, proxy)
+- **Security** — Optional authentication with group-based permissions (50+ granular), JWT tokens, API key support
+
+## Configuration
+
+| Variable | Default | Description |
+|---|---|---|
+| `TZ` | `UTC` | Timezone (e.g. `America/New_York`, `Europe/Berlin`) |
+| `PORT` | `8000` | Web UI port |
+| `PUID` | `1000` | User ID for file permissions |
+| `PGID` | `1000` | Group ID for file permissions |
+| `DEBUG` | `false` | Enable debug logging |
+
+## Volumes
+
+| Path | Purpose |
+|---|---|
+| `/app/data` | Database, archived prints, thumbnails |
+| `/app/logs` | Application logs |
+
+## Docker Compose
+
+```yaml
+services:
+  bambuddy:
+    image: maziggy/bambuddy:latest
+    container_name: bambuddy
+    network_mode: host
+    environment:
+      - TZ=America/New_York
+      - PUID=1000
+      - PGID=1000
+    volumes:
+      - bambuddy_data:/app/data
+      - bambuddy_logs:/app/logs
+    restart: unless-stopped
+
+volumes:
+  bambuddy_data:
+  bambuddy_logs:
+```
+
+> **macOS/Windows:** Docker Desktop doesn't support `network_mode: host`. Replace it with `ports: ["8000:8000"]` and add printers manually by IP.
+
+## Updating
+
+```bash
+docker compose pull && docker compose up -d
+```
+
+## Supported Printers
+
+| Series | Models | Status |
+|---|---|---|
+| H2 | H2D | Tested |
+| X1 | X1 Carbon, X1E | Tested |
+| P1 | P1P, P1S | Compatible |
+| P2 | P2S | Compatible |
+| A1 | A1, A1 Mini | Compatible |
+
+All printers require **Developer Mode** enabled for LAN access.
+
+## Links
+
+- **Website:** [bambuddy.cool](https://bambuddy.cool)
+- **Documentation:** [wiki.bambuddy.cool](http://wiki.bambuddy.cool)
+- **GitHub:** [github.com/maziggy/bambuddy](https://github.com/maziggy/bambuddy)
+- **Discord:** [discord.gg/aFS3ZfScHM](https://discord.gg/aFS3ZfScHM)
+- **Issues:** [GitHub Issues](https://github.com/maziggy/bambuddy/issues)
+
+## License
+
+MIT License - see [LICENSE](https://github.com/maziggy/bambuddy/blob/main/LICENSE) for details.

+ 11 - 11
backend/app/api/routes/archives.py

@@ -1886,7 +1886,7 @@ async def get_archive_capabilities(
                             if "<vertex" in content or "<mesh" in content:
                             if "<vertex" in content or "<mesh" in content:
                                 found_mesh = True
                                 found_mesh = True
                                 break
                                 break
-                        except (KeyError, UnicodeDecodeError):
+                        except Exception:
                             pass  # Skip unreadable .model entries in archive
                             pass  # Skip unreadable .model entries in archive
 
 
                 # Extract filament colors from project_settings.config
                 # Extract filament colors from project_settings.config
@@ -1928,7 +1928,7 @@ async def get_archive_capabilities(
                             for color in raw_colors:
                             for color in raw_colors:
                                 if color and isinstance(color, str):
                                 if color and isinstance(color, str):
                                     colors.append(color)
                                     colors.append(color)
-                    except (json.JSONDecodeError, KeyError, ValueError, TypeError):
+                    except Exception:
                         pass  # Skip malformed project_settings.config
                         pass  # Skip malformed project_settings.config
         except zipfile.BadZipFile:
         except zipfile.BadZipFile:
             pass  # File is not a valid zip/3MF archive
             pass  # File is not a valid zip/3MF archive
@@ -1961,7 +1961,7 @@ async def get_archive_capabilities(
                             if "<vertex" in content or "<mesh" in content:
                             if "<vertex" in content or "<mesh" in content:
                                 has_model = True
                                 has_model = True
                                 break
                                 break
-                        except (KeyError, UnicodeDecodeError):
+                        except Exception:
                             pass  # Skip unreadable .model entries in archive
                             pass  # Skip unreadable .model entries in archive
 
 
             # Extract filament colors from slice_info.config (for gcode preview)
             # Extract filament colors from slice_info.config (for gcode preview)
@@ -1995,7 +1995,7 @@ async def get_archive_capabilities(
                         max_tool = max(filament_map.keys())
                         max_tool = max(filament_map.keys())
                         for i in range(max_tool + 1):
                         for i in range(max_tool + 1):
                             slice_colors.append(filament_map.get(i, "#00AE42"))
                             slice_colors.append(filament_map.get(i, "#00AE42"))
-                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
+                except Exception:
                     pass  # Skip malformed slice_info.config XML
                     pass  # Skip malformed slice_info.config XML
 
 
             # Use slice_info colors if we don't have colors from source yet
             # Use slice_info colors if we don't have colors from source yet
@@ -2041,7 +2041,7 @@ async def get_archive_capabilities(
                                 for color in raw_colors:
                                 for color in raw_colors:
                                     if color and isinstance(color, str):
                                     if color and isinstance(color, str):
                                         filament_colors.append(color)
                                         filament_colors.append(color)
-                    except (json.JSONDecodeError, KeyError, ValueError, TypeError):
+                    except Exception:
                         pass  # Skip malformed project_settings.config
                         pass  # Skip malformed project_settings.config
 
 
     except zipfile.BadZipFile:
     except zipfile.BadZipFile:
@@ -2130,7 +2130,7 @@ async def get_plate_preview(
                     plate_elem = root.find(".//plate/metadata[@key='index']")
                     plate_elem = root.find(".//plate/metadata[@key='index']")
                     if plate_elem is not None:
                     if plate_elem is not None:
                         plate_num = int(plate_elem.get("value", "1"))
                         plate_num = int(plate_elem.get("value", "1"))
-                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
+                except Exception:
                     pass  # Default plate_num=1 if slice_info is missing or malformed
                     pass  # Default plate_num=1 if slice_info is missing or malformed
 
 
             # Try plate-specific image first, then fall back to plate_1
             # Try plate-specific image first, then fall back to plate_1
@@ -2376,7 +2376,7 @@ async def get_archive_plates(
                                         plate_object_ids.setdefault(plater_id, [])
                                         plate_object_ids.setdefault(plater_id, [])
                                         if obj_id not in plate_object_ids[plater_id]:
                                         if obj_id not in plate_object_ids[plater_id]:
                                             plate_object_ids[plater_id].append(obj_id)
                                             plate_object_ids[plater_id].append(obj_id)
-                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
+                except Exception:
                     pass  # model_settings.config parsing is optional
                     pass  # model_settings.config parsing is optional
 
 
             # Parse slice_info.config for plate metadata
             # Parse slice_info.config for plate metadata
@@ -2475,7 +2475,7 @@ async def get_archive_plates(
                             names.append(obj_name)
                             names.append(obj_name)
                     if names:
                     if names:
                         plate_json_objects[plate_index] = names
                         plate_json_objects[plate_index] = names
-                except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError):
+                except Exception:
                     continue
                     continue
 
 
             # Build plate list
             # Build plate list
@@ -2512,7 +2512,7 @@ async def get_archive_plates(
                     }
                     }
                 )
                 )
 
 
-    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+    except Exception as e:
         logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
         logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
 
 
     return {
     return {
@@ -2548,7 +2548,7 @@ async def get_plate_thumbnail(
             if thumb_path in zf.namelist():
             if thumb_path in zf.namelist():
                 data = zf.read(thumb_path)
                 data = zf.read(thumb_path)
                 return Response(content=data, media_type="image/png")
                 return Response(content=data, media_type="image/png")
-    except (zipfile.BadZipFile, KeyError, OSError):
+    except Exception:
         pass  # Fall through to 404 if archive is unreadable or thumbnail missing
         pass  # Fall through to 404 if archive is unreadable or thumbnail missing
 
 
     raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
     raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
@@ -2664,7 +2664,7 @@ async def get_filament_requirements(
             # Sort by slot ID
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
             filaments.sort(key=lambda x: x["slot_id"])
 
 
-    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+    except Exception as e:
         logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
         logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
 
 
     return {
     return {

+ 11 - 11
backend/app/api/routes/library.py

@@ -175,7 +175,7 @@ def extract_gcode_thumbnail(file_path: Path) -> bytes | None:
                     thumbnail_lines.append(data_line)
                     thumbnail_lines.append(data_line)
 
 
         return thumbnail_data
         return thumbnail_data
-    except OSError as e:
+    except Exception as e:
         logger.warning("Failed to extract gcode thumbnail: %s", e)
         logger.warning("Failed to extract gcode thumbnail: %s", e)
         return None
         return None
 
 
@@ -774,7 +774,7 @@ async def upload_file(
                     return obj
                     return obj
 
 
                 metadata = clean_metadata(raw_metadata)
                 metadata = clean_metadata(raw_metadata)
-            except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e:
+            except Exception as e:
                 logger.warning("Failed to parse 3MF: %s", e)
                 logger.warning("Failed to parse 3MF: %s", e)
 
 
         elif ext == ".gcode":
         elif ext == ".gcode":
@@ -787,7 +787,7 @@ async def upload_file(
                     with open(thumb_path, "wb") as f:
                     with open(thumb_path, "wb") as f:
                         f.write(thumbnail_data)
                         f.write(thumbnail_data)
                     thumbnail_path = str(thumb_path)
                     thumbnail_path = str(thumb_path)
-            except OSError as e:
+            except Exception as e:
                 logger.warning("Failed to extract gcode thumbnail: %s", e)
                 logger.warning("Failed to extract gcode thumbnail: %s", e)
 
 
         elif ext.lower() in IMAGE_EXTENSIONS:
         elif ext.lower() in IMAGE_EXTENSIONS:
@@ -867,7 +867,7 @@ async def extract_zip_file(
             content = await file.read()
             content = await file.read()
             tmp.write(content)
             tmp.write(content)
             tmp_path = tmp.name
             tmp_path = tmp.name
-    except OSError as e:
+    except Exception as e:
         raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
         raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
 
 
     extracted_files: list[ZipExtractResult] = []
     extracted_files: list[ZipExtractResult] = []
@@ -1013,7 +1013,7 @@ async def extract_zip_file(
                                 return obj
                                 return obj
 
 
                             metadata = clean_metadata(raw_metadata)
                             metadata = clean_metadata(raw_metadata)
-                        except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e:
+                        except Exception as e:
                             logger.warning("Failed to parse 3MF from ZIP: %s", e)
                             logger.warning("Failed to parse 3MF from ZIP: %s", e)
 
 
                     elif ext == ".gcode":
                     elif ext == ".gcode":
@@ -1025,7 +1025,7 @@ async def extract_zip_file(
                                 with open(thumb_path, "wb") as f:
                                 with open(thumb_path, "wb") as f:
                                     f.write(thumbnail_data)
                                     f.write(thumbnail_data)
                                 thumbnail_path = str(thumb_path)
                                 thumbnail_path = str(thumb_path)
-                        except OSError as e:
+                        except Exception as e:
                             logger.warning("Failed to extract gcode thumbnail from ZIP: %s", e)
                             logger.warning("Failed to extract gcode thumbnail from ZIP: %s", e)
 
 
                     elif ext.lower() in IMAGE_EXTENSIONS:
                     elif ext.lower() in IMAGE_EXTENSIONS:
@@ -1423,7 +1423,7 @@ async def get_library_file_plates(
                                         plate_object_ids.setdefault(plater_id, [])
                                         plate_object_ids.setdefault(plater_id, [])
                                         if obj_id not in plate_object_ids[plater_id]:
                                         if obj_id not in plate_object_ids[plater_id]:
                                             plate_object_ids[plater_id].append(obj_id)
                                             plate_object_ids[plater_id].append(obj_id)
-                except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
+                except Exception:
                     pass  # model_settings.config is optional; skip if missing or malformed
                     pass  # model_settings.config is optional; skip if missing or malformed
 
 
             # Parse slice_info.config for plate metadata
             # Parse slice_info.config for plate metadata
@@ -1516,7 +1516,7 @@ async def get_library_file_plates(
                             names.append(obj_name)
                             names.append(obj_name)
                     if names:
                     if names:
                         plate_json_objects[plate_index] = names
                         plate_json_objects[plate_index] = names
-                except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError):
+                except Exception:
                     continue
                     continue
 
 
             # Build plate list
             # Build plate list
@@ -1553,7 +1553,7 @@ async def get_library_file_plates(
                     }
                     }
                 )
                 )
 
 
-    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+    except Exception as e:
         logger.warning("Failed to parse plates from library file %s: %s", file_id, e)
         logger.warning("Failed to parse plates from library file %s: %s", file_id, e)
 
 
     return {
     return {
@@ -1589,7 +1589,7 @@ async def get_library_file_plate_thumbnail(
             if thumb_path in zf.namelist():
             if thumb_path in zf.namelist():
                 data = zf.read(thumb_path)
                 data = zf.read(thumb_path)
                 return Response(content=data, media_type="image/png")
                 return Response(content=data, media_type="image/png")
-    except (zipfile.BadZipFile, KeyError, OSError):
+    except Exception:
         pass  # Archive unreadable or thumbnail missing; fall through to 404
         pass  # Archive unreadable or thumbnail missing; fall through to 404
 
 
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
@@ -1711,7 +1711,7 @@ async def get_library_file_filament_requirements(
             # Sort by slot ID
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
             filaments.sort(key=lambda x: x["slot_id"])
 
 
-    except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
+    except Exception as e:
         logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
         logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
 
 
     return {
     return {

+ 2 - 2
backend/app/api/routes/print_queue.py

@@ -92,7 +92,7 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
                     if used_grams > 0 and filament_type:
                     if used_grams > 0 and filament_type:
                         types.add(filament_type)
                         types.add(filament_type)
 
 
-    except (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e:
+    except Exception as e:
         logger.warning("Failed to extract filament types from %s: %s", file_path, e)
         logger.warning("Failed to extract filament types from %s: %s", file_path, e)
 
 
     return sorted(types)
     return sorted(types)
@@ -144,7 +144,7 @@ def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -
                                 return int(meta.get("value", "0"))
                                 return int(meta.get("value", "0"))
                             except ValueError:
                             except ValueError:
                                 return None
                                 return None
-    except (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e:
+    except Exception as e:
         logger.warning("Failed to extract print time from %s: %s", file_path, e)
         logger.warning("Failed to extract print time from %s: %s", file_path, e)
 
 
     return None
     return None

+ 1 - 1
backend/app/api/routes/printers.py

@@ -1113,7 +1113,7 @@ async def get_printer_file_plate_thumbnail(
             if thumb_path in zf.namelist():
             if thumb_path in zf.namelist():
                 image_data = zf.read(thumb_path)
                 image_data = zf.read(thumb_path)
                 return Response(content=image_data, media_type="image/png")
                 return Response(content=image_data, media_type="image/png")
-    except (zipfile.BadZipFile, KeyError, OSError):
+    except Exception:
         pass  # Corrupt or unreadable 3MF; fall through to 404
         pass  # Corrupt or unreadable 3MF; fall through to 404
 
 
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")

+ 11 - 12
backend/app/services/archive.py

@@ -8,7 +8,6 @@ from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 from defusedxml import ElementTree as ET
 from defusedxml import ElementTree as ET
-from defusedxml.ElementTree import ParseError as XMLParseError
 from sqlalchemy import and_, or_, select
 from sqlalchemy import and_, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
@@ -57,7 +56,7 @@ class ThreeMFParser:
                 self.metadata.pop("_slice_filament_type", None)
                 self.metadata.pop("_slice_filament_type", None)
                 self.metadata.pop("_slice_filament_color", None)
                 self.metadata.pop("_slice_filament_color", None)
                 self.metadata.pop("_plate_index", None)
                 self.metadata.pop("_plate_index", None)
-        except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError):
+        except Exception:
             pass  # Return whatever metadata was extracted before the error
             pass  # Return whatever metadata was extracted before the error
         return self.metadata
         return self.metadata
 
 
@@ -152,7 +151,7 @@ class ThreeMFParser:
                         self.metadata["_slice_filament_type"] = ", ".join(types)
                         self.metadata["_slice_filament_type"] = ", ".join(types)
                     if colors:
                     if colors:
                         self.metadata["_slice_filament_color"] = ",".join(colors)
                         self.metadata["_slice_filament_color"] = ",".join(colors)
-        except (KeyError, ValueError, XMLParseError, UnicodeDecodeError):
+        except Exception:
             pass  # Skip unparseable slice_info metadata
             pass  # Skip unparseable slice_info metadata
 
 
     def _parse_project_settings(self, zf: zipfile.ZipFile):
     def _parse_project_settings(self, zf: zipfile.ZipFile):
@@ -166,7 +165,7 @@ class ThreeMFParser:
                     self._extract_print_settings(data)
                     self._extract_print_settings(data)
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     pass  # Skip malformed project_settings JSON
                     pass  # Skip malformed project_settings JSON
-        except (KeyError, ValueError, UnicodeDecodeError):
+        except Exception:
             pass  # Skip unreadable project settings file
             pass  # Skip unreadable project settings file
 
 
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
@@ -196,7 +195,7 @@ class ThreeMFParser:
 
 
                     raw_model = match.group(1).strip()
                     raw_model = match.group(1).strip()
                     self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
                     self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
-        except (KeyError, ValueError, UnicodeDecodeError):
+        except Exception:
             pass  # G-code header parsing is best-effort; metadata may come from other sources
             pass  # G-code header parsing is best-effort; metadata may come from other sources
 
 
     def _extract_filament_info(self, data: dict):
     def _extract_filament_info(self, data: dict):
@@ -237,7 +236,7 @@ class ThreeMFParser:
             if non_support_colors:
             if non_support_colors:
                 self.metadata["filament_color"] = ",".join(non_support_colors)
                 self.metadata["filament_color"] = ",".join(non_support_colors)
 
 
-        except (KeyError, ValueError, TypeError, IndexError):
+        except Exception:
             pass  # Filament info is optional; fall back to slice_info values
             pass  # Filament info is optional; fall back to slice_info values
 
 
     def _extract_print_settings(self, data: dict):
     def _extract_print_settings(self, data: dict):
@@ -284,7 +283,7 @@ class ThreeMFParser:
                 from backend.app.utils.printer_models import normalize_printer_model
                 from backend.app.utils.printer_models import normalize_printer_model
 
 
                 self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
                 self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
-        except (KeyError, ValueError, TypeError):
+        except Exception:
             pass  # Print settings are optional; missing values are left unset
             pass  # Print settings are optional; missing values are left unset
 
 
     def _extract_settings_from_content(self, content: str):
     def _extract_settings_from_content(self, content: str):
@@ -353,7 +352,7 @@ class ThreeMFParser:
             if "Title" in makerworld_fields:
             if "Title" in makerworld_fields:
                 self.metadata["print_name"] = makerworld_fields["Title"]
                 self.metadata["print_name"] = makerworld_fields["Title"]
 
 
-        except (KeyError, ValueError, UnicodeDecodeError):
+        except Exception:
             pass  # MakerWorld/3dmodel metadata is optional
             pass  # MakerWorld/3dmodel metadata is optional
 
 
     def _extract_thumbnail(self, zf: zipfile.ZipFile):
     def _extract_thumbnail(self, zf: zipfile.ZipFile):
@@ -478,7 +477,7 @@ def extract_printable_objects_from_3mf(
                     except ValueError:
                     except ValueError:
                         pass  # Skip objects with non-numeric identify_id
                         pass  # Skip objects with non-numeric identify_id
 
 
-    except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError):
+    except Exception:
         pass  # Return empty dict if 3MF is corrupt or unreadable
         pass  # Return empty dict if 3MF is corrupt or unreadable
 
 
     if include_positions:
     if include_positions:
@@ -598,7 +597,7 @@ class ProjectPageParser:
                                 }
                                 }
                             )
                             )
 
 
-        except (KeyError, ValueError, zipfile.BadZipFile, UnicodeDecodeError) as e:
+        except Exception as e:
             result["_error"] = str(e)
             result["_error"] = str(e)
 
 
         return result
         return result
@@ -623,7 +622,7 @@ class ProjectPageParser:
                     }
                     }
                     content_type = content_types.get(ext, "application/octet-stream")
                     content_type = content_types.get(ext, "application/octet-stream")
                     return (data, content_type)
                     return (data, content_type)
-        except (KeyError, zipfile.BadZipFile, OSError):
+        except Exception:
             pass  # Return None if image cannot be extracted from 3MF
             pass  # Return None if image cannot be extracted from 3MF
         return None
         return None
 
 
@@ -684,7 +683,7 @@ class ProjectPageParser:
             shutil.move(tmp_path, self.file_path)
             shutil.move(tmp_path, self.file_path)
             return True
             return True
 
 
-        except (zipfile.BadZipFile, OSError, UnicodeDecodeError, KeyError, ValueError):
+        except Exception:
             # Clean up temp file if it exists
             # Clean up temp file if it exists
             if "tmp_path" in locals() and tmp_path.exists():
             if "tmp_path" in locals() and tmp_path.exists():
                 tmp_path.unlink()
                 tmp_path.unlink()

+ 17 - 28
backend/app/services/bambu_ftp.py

@@ -171,7 +171,7 @@ class BambuFTPClient:
             logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e)
             logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e)
             self._ftp = None
             self._ftp = None
             return False
             return False
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
             self._ftp = None
             self._ftp = None
             return False
             return False
@@ -181,7 +181,7 @@ class BambuFTPClient:
         if self._ftp:
         if self._ftp:
             try:
             try:
                 self._ftp.quit()
                 self._ftp.quit()
-            except (OSError, ftplib.error_reply):
+            except (OSError, ftplib.Error):
                 pass  # Best-effort FTP cleanup; connection may already be closed
                 pass  # Best-effort FTP cleanup; connection may already be closed
             self._ftp = None
             self._ftp = None
 
 
@@ -239,7 +239,7 @@ class BambuFTPClient:
                         file_entry["mtime"] = mtime
                         file_entry["mtime"] = mtime
                     files.append(file_entry)
                     files.append(file_entry)
             logger.debug("Listed %s files in %s", len(files), path)
             logger.debug("Listed %s files in %s", len(files), path)
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             logger.info("FTP list_files failed for %s: %s", path, e)
             logger.info("FTP list_files failed for %s: %s", path, e)
 
 
         return files
         return files
@@ -253,7 +253,7 @@ class BambuFTPClient:
             buffer = BytesIO()
             buffer = BytesIO()
             self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
             self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
             return buffer.getvalue()
             return buffer.getvalue()
-        except (OSError, ftplib.error_reply):
+        except (OSError, ftplib.Error):
             return None
             return None
 
 
     def download_to_file(self, remote_path: str, local_path: Path) -> bool:
     def download_to_file(self, remote_path: str, local_path: Path) -> bool:
@@ -271,7 +271,7 @@ class BambuFTPClient:
             file_size = local_path.stat().st_size if local_path.exists() else 0
             file_size = local_path.stat().st_size if local_path.exists() else 0
             logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
             logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
             return True
             return True
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             # Log at INFO level so we can see failures in normal logs
             # Log at INFO level so we can see failures in normal logs
             logger.info("FTP download failed for %s: %s", remote_path, e)
             logger.info("FTP download failed for %s: %s", remote_path, e)
             # Clean up partial file if it exists
             # Clean up partial file if it exists
@@ -302,7 +302,7 @@ class BambuFTPClient:
         try:
         try:
             results["pwd"] = self._ftp.pwd()
             results["pwd"] = self._ftp.pwd()
             logger.debug("FTP current directory: %s", results["pwd"])
             logger.debug("FTP current directory: %s", results["pwd"])
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             results["errors"].append(f"PWD failed: {e}")
             results["errors"].append(f"PWD failed: {e}")
             logger.debug("FTP PWD failed: %s", e)
             logger.debug("FTP PWD failed: %s", e)
 
 
@@ -314,7 +314,7 @@ class BambuFTPClient:
             results["can_list_root"] = True
             results["can_list_root"] = True
             results["root_files"] = items[:10]  # First 10 entries
             results["root_files"] = items[:10]  # First 10 entries
             logger.debug("FTP root listing (%s items): %s", len(items), items[:5])
             logger.debug("FTP root listing (%s items): %s", len(items), items[:5])
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             results["errors"].append(f"LIST / failed: {e}")
             results["errors"].append(f"LIST / failed: {e}")
             logger.debug("FTP LIST / failed: %s", e)
             logger.debug("FTP LIST / failed: %s", e)
 
 
@@ -325,7 +325,7 @@ class BambuFTPClient:
             self._ftp.retrlines("LIST", items.append)
             self._ftp.retrlines("LIST", items.append)
             results["can_list_cache"] = True
             results["can_list_cache"] = True
             logger.debug("FTP /cache listing: %s items", len(items))
             logger.debug("FTP /cache listing: %s items", len(items))
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             results["errors"].append(f"LIST /cache failed: {e}")
             results["errors"].append(f"LIST /cache failed: {e}")
             logger.debug("FTP LIST /cache failed: %s", e)
             logger.debug("FTP LIST /cache failed: %s", e)
 
 
@@ -333,7 +333,7 @@ class BambuFTPClient:
         try:
         try:
             results["storage_info"] = self.get_storage_info()
             results["storage_info"] = self.get_storage_info()
             logger.debug("FTP storage info: %s", results["storage_info"])
             logger.debug("FTP storage info: %s", results["storage_info"])
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             results["errors"].append(f"Storage info failed: {e}")
             results["errors"].append(f"Storage info failed: {e}")
 
 
         return results
         return results
@@ -353,17 +353,6 @@ class BambuFTPClient:
             file_size = local_path.stat().st_size if local_path.exists() else 0
             file_size = local_path.stat().st_size if local_path.exists() else 0
             logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
             logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
 
 
-            # Run storage diagnostics before upload (debug)
-            logger.debug("Running pre-upload storage diagnostics...")
-            diag = self.diagnose_storage()
-            logger.info(
-                f"FTP storage diagnostics: can_list_root={diag['can_list_root']}, "
-                f"can_list_cache={diag['can_list_cache']}, "
-                f"storage={diag['storage_info']}, errors={diag['errors']}"
-            )
-            if diag["root_files"]:
-                logger.debug("FTP root directory contents: %s", diag["root_files"])
-
             uploaded = 0
             uploaded = 0
 
 
             # Use manual transfer instead of storbinary() for A1 compatibility
             # Use manual transfer instead of storbinary() for A1 compatibility
@@ -414,7 +403,7 @@ class BambuFTPClient:
             elif error_code == "552":
             elif error_code == "552":
                 logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
                 logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
             return False
             return False
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             logger.error("FTP upload failed for %s: %s (type: %s)", remote_path, e, type(e).__name__)
             logger.error("FTP upload failed for %s: %s (type: %s)", remote_path, e, type(e).__name__)
             return False
             return False
 
 
@@ -443,7 +432,7 @@ class BambuFTPClient:
 
 
             conn.close()
             conn.close()
             return True
             return True
-        except (OSError, ftplib.error_reply):
+        except (OSError, ftplib.Error):
             return False
             return False
 
 
     def delete_file(self, remote_path: str) -> bool:
     def delete_file(self, remote_path: str) -> bool:
@@ -454,7 +443,7 @@ class BambuFTPClient:
         try:
         try:
             self._ftp.delete(remote_path)
             self._ftp.delete(remote_path)
             return True
             return True
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             logger.warning("Failed to delete %s: %s", remote_path, e)
             logger.warning("Failed to delete %s: %s", remote_path, e)
             return False
             return False
 
 
@@ -465,7 +454,7 @@ class BambuFTPClient:
 
 
         try:
         try:
             return self._ftp.size(remote_path)
             return self._ftp.size(remote_path)
-        except (OSError, ftplib.error_reply):
+        except (OSError, ftplib.Error):
             return None
             return None
 
 
     def get_storage_info(self) -> dict | None:
     def get_storage_info(self) -> dict | None:
@@ -484,13 +473,13 @@ class BambuFTPClient:
                 parts = response.split()
                 parts = response.split()
                 if len(parts) >= 2:
                 if len(parts) >= 2:
                     result["free_bytes"] = int(parts[1])
                     result["free_bytes"] = int(parts[1])
-        except (OSError, ftplib.error_reply) as e:
+        except (OSError, ftplib.Error) as e:
             logger.debug("AVBL command not supported: %s", e)
             logger.debug("AVBL command not supported: %s", e)
             # Try STAT command as fallback
             # Try STAT command as fallback
             try:
             try:
                 response = self._ftp.sendcmd("STAT")
                 response = self._ftp.sendcmd("STAT")
                 logger.debug("STAT response: %s", response)
                 logger.debug("STAT response: %s", response)
-            except (OSError, ftplib.error_reply):
+            except (OSError, ftplib.Error):
                 pass  # Both AVBL and STAT unsupported; storage info will rely on directory scan
                 pass  # Both AVBL and STAT unsupported; storage info will rely on directory scan
 
 
         # Calculate used space by listing root directories
         # Calculate used space by listing root directories
@@ -511,11 +500,11 @@ class BambuFTPClient:
                                 total_used += int(parts[4])
                                 total_used += int(parts[4])
                             except ValueError:
                             except ValueError:
                                 pass  # Skip entries with non-numeric size fields
                                 pass  # Skip entries with non-numeric size fields
-                except (OSError, ftplib.error_reply):
+                except (OSError, ftplib.Error):
                     pass  # Directory may not exist on this printer model; skip it
                     pass  # Directory may not exist on this printer model; skip it
 
 
             result["used_bytes"] = total_used
             result["used_bytes"] = total_used
-        except (OSError, ftplib.error_reply):
+        except (OSError, ftplib.Error):
             pass  # Storage scan failed; return whatever info was collected above
             pass  # Storage scan failed; return whatever info was collected above
 
 
         return result if result else None
         return result if result else None

+ 3 - 3
backend/app/services/firmware_check.py

@@ -103,7 +103,7 @@ class FirmwareCheckService:
                     logger.info("Got Bambu Lab build ID: %s", self._build_id)
                     logger.info("Got Bambu Lab build ID: %s", self._build_id)
                     return self._build_id
                     return self._build_id
             logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
             logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
-        except (httpx.HTTPError, OSError) as e:
+        except Exception as e:
             logger.error("Error fetching Bambu Lab build ID: %s", e)
             logger.error("Error fetching Bambu Lab build ID: %s", e)
 
 
         return self._build_id  # Return cached value if available
         return self._build_id  # Return cached value if available
@@ -138,7 +138,7 @@ class FirmwareCheckService:
                 # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
                 # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
                 logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
                 logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
 
 
-        except (httpx.HTTPError, OSError, KeyError, ValueError) as e:
+        except Exception as e:
             # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
             # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
             logger.error("Error fetching firmware for %s: %s", api_key, e)
             logger.error("Error fetching firmware for %s: %s", api_key, e)
 
 
@@ -359,7 +359,7 @@ class FirmwareCheckService:
 
 
             return original_path
             return original_path
 
 
-        except (httpx.HTTPError, OSError) as e:
+        except Exception as e:
             logger.error("Firmware download failed: %s", e)
             logger.error("Firmware download failed: %s", e)
             if temp_path.exists():
             if temp_path.exists():
                 try:
                 try:

+ 6 - 6
backend/app/services/homeassistant.py

@@ -65,7 +65,7 @@ class HomeAssistantService:
                     "reachable": True,
                     "reachable": True,
                     "device_name": data.get("attributes", {}).get("friendly_name"),
                     "device_name": data.get("attributes", {}).get("friendly_name"),
                 }
                 }
-        except (httpx.HTTPError, OSError, KeyError) as e:
+        except Exception as e:
             logger.warning("Failed to get HA entity state for %s: %s", plug.ha_entity_id, e)
             logger.warning("Failed to get HA entity state for %s: %s", plug.ha_entity_id, e)
             return {"state": None, "reachable": False, "device_name": None}
             return {"state": None, "reachable": False, "device_name": None}
 
 
@@ -106,7 +106,7 @@ class HomeAssistantService:
                 )
                 )
                 response.raise_for_status()
                 response.raise_for_status()
                 return True
                 return True
-        except (httpx.HTTPError, OSError) as e:
+        except Exception as e:
             logger.warning("Failed to %s HA entity %s: %s", action, plug.ha_entity_id, e)
             logger.warning("Failed to %s HA entity %s: %s", action, plug.ha_entity_id, e)
             return False
             return False
 
 
@@ -166,7 +166,7 @@ class HomeAssistantService:
                     "apparent_power": None,
                     "apparent_power": None,
                     "reactive_power": None,
                     "reactive_power": None,
                 }
                 }
-        except (httpx.HTTPError, OSError, KeyError, ValueError) as e:
+        except Exception as e:
             logger.debug("Failed to get HA energy data: %s", e)
             logger.debug("Failed to get HA energy data: %s", e)
             return None
             return None
 
 
@@ -181,7 +181,7 @@ class HomeAssistantService:
             state = response.json().get("state")
             state = response.json().get("state")
             if state and state not in ("unknown", "unavailable"):
             if state and state not in ("unknown", "unavailable"):
                 return float(state)
                 return float(state)
-        except (httpx.HTTPError, OSError, ValueError):
+        except Exception:
             pass  # Sensor read is best-effort; caller handles None
             pass  # Sensor read is best-effort; caller handles None
         return None
         return None
 
 
@@ -284,7 +284,7 @@ class HomeAssistantService:
                     )
                     )
 
 
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
-        except (httpx.HTTPError, OSError, KeyError) as e:
+        except Exception as e:
             logger.warning("Failed to list HA entities: %s", e)
             logger.warning("Failed to list HA entities: %s", e)
             return []
             return []
 
 
@@ -330,7 +330,7 @@ class HomeAssistantService:
                         )
                         )
 
 
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
-        except (httpx.HTTPError, OSError, KeyError) as e:
+        except Exception as e:
             logger.warning("Failed to list HA sensor entities: %s", e)
             logger.warning("Failed to list HA sensor entities: %s", e)
             return []
             return []
 
 

+ 3 - 4
backend/app/utils/threemf_tools.py

@@ -12,7 +12,6 @@ import zipfile
 from pathlib import Path
 from pathlib import Path
 
 
 import defusedxml.ElementTree as ET
 import defusedxml.ElementTree as ET
-from defusedxml.ElementTree import ParseError as XMLParseError
 
 
 # Default filament properties
 # Default filament properties
 DEFAULT_FILAMENT_DIAMETER = 1.75  # mm
 DEFAULT_FILAMENT_DIAMETER = 1.75  # mm
@@ -177,7 +176,7 @@ def extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int
             gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
             gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
 
 
             return parse_gcode_layer_filament_usage(gcode_content)
             return parse_gcode_layer_filament_usage(gcode_content)
-    except (zipfile.BadZipFile, OSError, UnicodeDecodeError):
+    except Exception:
         return None
         return None
 
 
 
 
@@ -259,7 +258,7 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
                             properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
                             properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     pass  # Skip malformed project_settings.config JSON
                     pass  # Skip malformed project_settings.config JSON
-    except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
+    except Exception:
         pass  # Return whatever properties were collected before the error
         pass  # Return whatever properties were collected before the error
 
 
     return properties
     return properties
@@ -303,7 +302,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
                         )
                         )
                 except (ValueError, TypeError):
                 except (ValueError, TypeError):
                     pass  # Skip filament entries with unparseable usage values
                     pass  # Skip filament entries with unparseable usage values
-    except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
+    except Exception:
         pass  # Return whatever usage data was collected before the error
         pass  # Return whatever usage data was collected before the error
 
 
     return filament_usage
     return filament_usage

+ 120 - 37
docker-publish.sh

@@ -1,20 +1,25 @@
 #!/bin/bash
 #!/bin/bash
-# Build and push multi-architecture Docker image to GitHub Container Registry
+# Build and push multi-architecture Docker image to GitHub Container Registry AND Docker Hub
 #
 #
 # Usage:
 # Usage:
-#   ./docker-publish.sh [version] [--parallel]
+#   ./docker-publish.sh [version] [--parallel] [--ghcr-only] [--dockerhub-only]
 #
 #
 # Examples:
 # Examples:
-#   ./docker-publish.sh 0.1.7b            # Sequential build (default)
-#   ./docker-publish.sh 0.1.7b --parallel # Build both archs simultaneously
+#   ./docker-publish.sh 0.1.9b            # Sequential build, push to both registries
+#   ./docker-publish.sh 0.1.9b --parallel # Build both archs simultaneously
+#   ./docker-publish.sh 0.1.9b --ghcr-only    # Only push to GHCR
+#   ./docker-publish.sh 0.1.9b --dockerhub-only # Only push to Docker Hub
 #
 #
 # Note: All versions are also tagged as 'latest'
 # Note: All versions are also tagged as 'latest'
 #
 #
 # Prerequisites:
 # Prerequisites:
-#   1. Log in to ghcr.io first:
+#   1. Log in to ghcr.io:
 #      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
 #      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
 #
 #
-#   2. Create a GitHub Personal Access Token with 'write:packages' scope:
+#   2. Log in to Docker Hub:
+#      docker login -u YOUR_USERNAME
+#
+#   3. Create a GitHub Personal Access Token with 'write:packages' scope:
 #      https://github.com/settings/tokens/new?scopes=write:packages
 #      https://github.com/settings/tokens/new?scopes=write:packages
 #
 #
 # Supported architectures:
 # Supported architectures:
@@ -24,9 +29,11 @@
 set -e
 set -e
 
 
 # Configuration
 # Configuration
-REGISTRY="ghcr.io"
+GHCR_REGISTRY="ghcr.io"
+DOCKERHUB_REGISTRY="docker.io"
 IMAGE_NAME="maziggy/bambuddy"
 IMAGE_NAME="maziggy/bambuddy"
-FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}"
+GHCR_IMAGE="${GHCR_REGISTRY}/${IMAGE_NAME}"
+DOCKERHUB_IMAGE="${DOCKERHUB_REGISTRY}/${IMAGE_NAME}"
 PLATFORMS="linux/amd64,linux/arm64"
 PLATFORMS="linux/amd64,linux/arm64"
 BUILDER_NAME="bambuddy-builder"
 BUILDER_NAME="bambuddy-builder"
 
 
@@ -40,11 +47,19 @@ NC='\033[0m' # No Color
 # Parse arguments
 # Parse arguments
 VERSION=""
 VERSION=""
 PARALLEL=false
 PARALLEL=false
+PUSH_GHCR=true
+PUSH_DOCKERHUB=true
 for arg in "$@"; do
 for arg in "$@"; do
     case $arg in
     case $arg in
         --parallel)
         --parallel)
             PARALLEL=true
             PARALLEL=true
             ;;
             ;;
+        --ghcr-only)
+            PUSH_DOCKERHUB=false
+            ;;
+        --dockerhub-only)
+            PUSH_GHCR=false
+            ;;
         *)
         *)
             if [ -z "$VERSION" ]; then
             if [ -z "$VERSION" ]; then
                 VERSION="$arg"
                 VERSION="$arg"
@@ -54,9 +69,11 @@ for arg in "$@"; do
 done
 done
 
 
 if [ -z "$VERSION" ]; then
 if [ -z "$VERSION" ]; then
-    echo -e "${YELLOW}Usage: $0 <version> [--parallel]${NC}"
-    echo "Example: $0 0.1.6"
-    echo "         $0 0.1.6 --parallel  # Build both architectures simultaneously"
+    echo -e "${YELLOW}Usage: $0 <version> [--parallel] [--ghcr-only] [--dockerhub-only]${NC}"
+    echo "Example: $0 0.1.9b"
+    echo "         $0 0.1.9b --parallel     # Build both architectures simultaneously"
+    echo "         $0 0.1.9b --ghcr-only    # Only push to GitHub Container Registry"
+    echo "         $0 0.1.9b --dockerhub-only # Only push to Docker Hub"
     exit 1
     exit 1
 fi
 fi
 
 
@@ -65,7 +82,7 @@ CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
 
 
 echo -e "${GREEN}================================================${NC}"
 echo -e "${GREEN}================================================${NC}"
 echo -e "${GREEN}  Building multi-arch image${NC}"
 echo -e "${GREEN}  Building multi-arch image${NC}"
-echo -e "${GREEN}  ${FULL_IMAGE}:${VERSION}${NC}"
+echo -e "${GREEN}  Version: ${VERSION}${NC}"
 echo -e "${GREEN}  Platforms: ${PLATFORMS}${NC}"
 echo -e "${GREEN}  Platforms: ${PLATFORMS}${NC}"
 echo -e "${GREEN}  CPU cores: ${CPU_COUNT}${NC}"
 echo -e "${GREEN}  CPU cores: ${CPU_COUNT}${NC}"
 if [ "$PARALLEL" = true ]; then
 if [ "$PARALLEL" = true ]; then
@@ -73,17 +90,33 @@ if [ "$PARALLEL" = true ]; then
 else
 else
     echo -e "${GREEN}  Mode: Sequential (amd64 → arm64)${NC}"
     echo -e "${GREEN}  Mode: Sequential (amd64 → arm64)${NC}"
 fi
 fi
+echo -e "${GREEN}  Registries:${NC}"
+if [ "$PUSH_GHCR" = true ]; then
+    echo -e "${GREEN}    - ${GHCR_IMAGE}${NC}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo -e "${GREEN}    - ${DOCKERHUB_IMAGE}${NC}"
+fi
 echo -e "${GREEN}================================================${NC}"
 echo -e "${GREEN}================================================${NC}"
 echo ""
 echo ""
 
 
-# Check if logged in to ghcr.io
-if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
-    echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
-    echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
-    echo ""
+# Check registry logins
+if [ "$PUSH_GHCR" = true ]; then
+    if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
+        echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
+        echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
+        echo ""
+    fi
 fi
 fi
 
 
-# Always tag as latest (in addition to version tag)
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    if ! grep -q "index.docker.io\|docker.io" ~/.docker/config.json 2>/dev/null; then
+        echo -e "${RED}Error: You are not logged in to Docker Hub${NC}"
+        echo "Run: docker login -u YOUR_USERNAME"
+        echo ""
+        exit 1
+    fi
+fi
 
 
 # Setup buildx builder if not exists
 # Setup buildx builder if not exists
 echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
 echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
@@ -110,23 +143,42 @@ if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
     docker run --privileged --rm tonistiigi/binfmt --install all
     docker run --privileged --rm tonistiigi/binfmt --install all
 fi
 fi
 
 
-# Build tags (always include latest)
-TAGS="-t ${FULL_IMAGE}:${VERSION} -t ${FULL_IMAGE}:latest"
-echo -e "${BLUE}[3/4] Building and pushing (version + latest)...${NC}"
+# Build tags for all target registries
+TAGS=""
+if [ "$PUSH_GHCR" = true ]; then
+    TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:latest"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION} -t ${DOCKERHUB_IMAGE}:latest"
+fi
+
+echo -e "${BLUE}[3/4] Building and pushing...${NC}"
 
 
 # Common build args (no cache to ensure clean builds)
 # Common build args (no cache to ensure clean builds)
 BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull"
 BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull"
 
 
 if [ "$PARALLEL" = true ]; then
 if [ "$PARALLEL" = true ]; then
-    # Parallel build: Build each architecture separately then combine
+    # Parallel build: Build each architecture separately then combine manifests
     echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}"
     echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}"
 
 
+    # Build per-arch staging tags for each target registry
+    ARCH_TAGS_AMD64=""
+    ARCH_TAGS_ARM64=""
+    if [ "$PUSH_GHCR" = true ]; then
+        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:${VERSION}-amd64"
+        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:${VERSION}-arm64"
+    fi
+    if [ "$PUSH_DOCKERHUB" = true ]; then
+        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:${VERSION}-amd64"
+        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:${VERSION}-arm64"
+    fi
+
     # Build amd64 in background
     # Build amd64 in background
     (
     (
         echo -e "${BLUE}[amd64] Starting build...${NC}"
         echo -e "${BLUE}[amd64] Starting build...${NC}"
         docker buildx build \
         docker buildx build \
             --platform linux/amd64 \
             --platform linux/amd64 \
-            -t "${FULL_IMAGE}:${VERSION}-amd64" \
+            ${ARCH_TAGS_AMD64} \
             ${BUILD_ARGS} \
             ${BUILD_ARGS} \
             --push \
             --push \
             . 2>&1 | sed 's/^/[amd64] /'
             . 2>&1 | sed 's/^/[amd64] /'
@@ -139,7 +191,7 @@ if [ "$PARALLEL" = true ]; then
         echo -e "${BLUE}[arm64] Starting build...${NC}"
         echo -e "${BLUE}[arm64] Starting build...${NC}"
         docker buildx build \
         docker buildx build \
             --platform linux/arm64 \
             --platform linux/arm64 \
-            -t "${FULL_IMAGE}:${VERSION}-arm64" \
+            ${ARCH_TAGS_ARM64} \
             ${BUILD_ARGS} \
             ${BUILD_ARGS} \
             --push \
             --push \
             . 2>&1 | sed 's/^/[arm64] /'
             . 2>&1 | sed 's/^/[arm64] /'
@@ -152,13 +204,23 @@ if [ "$PARALLEL" = true ]; then
     wait $PID_AMD64
     wait $PID_AMD64
     wait $PID_ARM64
     wait $PID_ARM64
 
 
-    # Create and push multi-arch manifest (version + latest)
-    echo -e "${BLUE}Creating multi-arch manifest...${NC}"
-    docker buildx imagetools create \
-        -t "${FULL_IMAGE}:${VERSION}" \
-        -t "${FULL_IMAGE}:latest" \
-        "${FULL_IMAGE}:${VERSION}-amd64" \
-        "${FULL_IMAGE}:${VERSION}-arm64"
+    # Create multi-arch manifests per registry (no cross-registry blob copies)
+    echo -e "${BLUE}Creating multi-arch manifests...${NC}"
+
+    if [ "$PUSH_GHCR" = true ]; then
+        echo -e "${BLUE}  Creating GHCR manifest...${NC}"
+        docker buildx imagetools create \
+            -t "${GHCR_IMAGE}:${VERSION}" -t "${GHCR_IMAGE}:latest" \
+            "${GHCR_IMAGE}:${VERSION}-amd64" \
+            "${GHCR_IMAGE}:${VERSION}-arm64"
+    fi
+    if [ "$PUSH_DOCKERHUB" = true ]; then
+        echo -e "${BLUE}  Creating Docker Hub manifest...${NC}"
+        docker buildx imagetools create \
+            -t "${DOCKERHUB_IMAGE}:${VERSION}" -t "${DOCKERHUB_IMAGE}:latest" \
+            "${DOCKERHUB_IMAGE}:${VERSION}-amd64" \
+            "${DOCKERHUB_IMAGE}:${VERSION}-arm64"
+    fi
 else
 else
     # Sequential build (default): Build both platforms in one command
     # Sequential build (default): Build both platforms in one command
     echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}"
     echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}"
@@ -170,19 +232,40 @@ else
         .
         .
 fi
 fi
 
 
-echo -e "${BLUE}[4/4] Verifying manifest...${NC}"
-docker buildx imagetools inspect "${FULL_IMAGE}:${VERSION}"
+echo -e "${BLUE}[4/4] Verifying manifests...${NC}"
+if [ "$PUSH_GHCR" = true ]; then
+    echo -e "${BLUE}GHCR:${NC}"
+    docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo -e "${BLUE}Docker Hub:${NC}"
+    docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${VERSION}"
+fi
 
 
 echo ""
 echo ""
 echo -e "${GREEN}================================================${NC}"
 echo -e "${GREEN}================================================${NC}"
-echo -e "${GREEN}✓ Successfully pushed multi-arch image:${NC}"
+echo -e "${GREEN}  Successfully pushed multi-arch image:${NC}"
 echo -e "${GREEN}================================================${NC}"
 echo -e "${GREEN}================================================${NC}"
-echo "  - ${FULL_IMAGE}:${VERSION}"
-echo "  - ${FULL_IMAGE}:latest"
+if [ "$PUSH_GHCR" = true ]; then
+    echo "  GHCR:"
+    echo "    - ${GHCR_IMAGE}:${VERSION}"
+    echo "    - ${GHCR_IMAGE}:latest"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo "  Docker Hub:"
+    echo "    - ${DOCKERHUB_IMAGE}:${VERSION}"
+    echo "    - ${DOCKERHUB_IMAGE}:latest"
+fi
 echo ""
 echo ""
 echo -e "${BLUE}Supported platforms:${NC}"
 echo -e "${BLUE}Supported platforms:${NC}"
 echo "  - linux/amd64 (Intel/AMD servers, desktops)"
 echo "  - linux/amd64 (Intel/AMD servers, desktops)"
 echo "  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
 echo "  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
 echo ""
 echo ""
 echo -e "${GREEN}Users can now run:${NC}"
 echo -e "${GREEN}Users can now run:${NC}"
-echo "  docker pull ${FULL_IMAGE}:${VERSION}"
+if [ "$PUSH_GHCR" = true ]; then
+    echo "  docker pull ${GHCR_IMAGE}:${VERSION}"
+fi
+if [ "$PUSH_DOCKERHUB" = true ]; then
+    echo "  docker pull ${DOCKERHUB_IMAGE}:${VERSION}"
+    echo "  docker pull ${IMAGE_NAME}:${VERSION}  # shorthand"
+fi