diff --git a/.env.example b/.env.example index d367927..5bfc87a 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,14 @@ SPOOLMAN_PORT=7912 # Default: FALSE #SPOOLMAN_METRICS_ENABLED=TRUE +# Collect items (filaments, materials, etc.) from an external database +# Set this to a URL of an external database. Set to an empty string to disable +# Default: https://donkie.github.io/SpoolmanDB/ +#EXTERNAL_DB_URL=https://myhost.com/spoolmandb/ +# Sync interval in seconds, set to 0 to disable automatic sync. It will only sync on start-up then. +# Default: 3600 +#EXTERNAL_DB_SYNC_INTERVAL=3600 + # Enable debug mode # If enabled, the client will accept requests from any host # This can be useful when developing, but is also a security risk diff --git a/spoolman/api/v1/externaldb.py b/spoolman/api/v1/externaldb.py new file mode 100644 index 0000000..3c9d38a --- /dev/null +++ b/spoolman/api/v1/externaldb.py @@ -0,0 +1,39 @@ +"""External database API.""" + +import logging + +from fastapi import APIRouter +from fastapi.responses import FileResponse + +from spoolman.externaldb import ExternalFilament, ExternalMaterial, get_filaments_file, get_materials_file + +router = APIRouter( + prefix="/external", + tags=["external"], +) + +# ruff: noqa: D103,B008 + +logger = logging.getLogger(__name__) + + +@router.get( + "/filaments", + name="Get all external filaments", + response_model_exclude_none=True, + response_model=list[ExternalFilament], +) +async def filaments() -> FileResponse: + """Get all external filaments.""" + return FileResponse(path=get_filaments_file(), media_type="application/json") + + +@router.get( + "/materials", + name="Get all external materials", + response_model_exclude_none=True, + response_model=list[ExternalMaterial], +) +async def materials() -> FileResponse: + """Get all external materials.""" + return FileResponse(path=get_materials_file(), media_type="application/json") diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index fd9949e..ea72938 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db from spoolman.exceptions import ItemNotFoundError from spoolman.ws import websocket_manager -from . import field, filament, models, other, setting, spool, vendor +from . import externaldb, field, filament, models, other, setting, spool, vendor logger = logging.getLogger(__name__) @@ -110,3 +110,4 @@ app.include_router(vendor.router) app.include_router(setting.router) app.include_router(field.router) app.include_router(other.router) +app.include_router(externaldb.router) diff --git a/spoolman/env.py b/spoolman/env.py index a5db3e0..40e2970 100644 --- a/spoolman/env.py +++ b/spoolman/env.py @@ -354,7 +354,7 @@ def check_write_permissions() -> None: # Try fixing it by chowning the directory to the current user logger.warning("Data directory is not writable, trying to fix it...") - if not chown_dir(get_data_dir()) or not can_write_to_data_dir(): + if not chown_dir(str(get_data_dir())) or not can_write_to_data_dir(): uid = os.getuid() gid = os.getgid() diff --git a/spoolman/externaldb.py b/spoolman/externaldb.py index 73f32cd..14ecbca 100644 --- a/spoolman/externaldb.py +++ b/spoolman/externaldb.py @@ -3,12 +3,15 @@ import datetime import logging import os +from pathlib import Path from typing import Optional import hishel -from pydantic import BaseModel, ConfigDict, Extra, Field +from pydantic import BaseModel, Field from scheduler.asyncio.scheduler import Scheduler +from spoolman import filecache + DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/" DEFAULT_SYNC_INTERVAL = 3600 @@ -18,26 +21,18 @@ cache_storage = hishel.AsyncFileStorage() logger = logging.getLogger(__name__) -class ExternalFilamentParameters(BaseModel): - model_config = ConfigDict( - extra=Extra.allow, - ) - - extruder_temp: Optional[float] = Field(default=None) - bed_temp: Optional[float] = Field(default=None) - - class ExternalFilament(BaseModel): - id: str = Field() - manufacturer: str = Field() - name: str = Field() - material: str = Field() - density: Optional[float] = Field(default=None) - weight: float = Field(default=None) - spool_weight: Optional[float] = Field(default=None) - diameter: float = Field(default=None) - color_hex: str = Field() - parameters: ExternalFilamentParameters = Field(ExternalFilamentParameters()) + id: str = Field(description="A unique ID for this filament.", example="polymaker_pla_polysonicblack_1000_175") + manufacturer: str = Field(description="Filament manufacturer.", example="Polymaker") + name: str = Field(description="Filament name.", example="Polysonic\u2122 Black") + material: str = Field(description="Filament material.", example="PLA") + density: Optional[float] = Field(default=None, description="Density in g/cm3.", example=1.23) + weight: float = Field(description="Net weight of a single spool.", example=1000) + spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", example=140) + diameter: float = Field(description="Filament in mm.", example=1.75) + color_hex: str = Field(description="Filament color code in hex format.", example="#2c3232") + extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", example=210) + bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", example=50) class ExternalFilamentsFile(BaseModel): @@ -45,27 +40,14 @@ class ExternalFilamentsFile(BaseModel): class ExternalMaterial(BaseModel): - material: str = Field() - density: float = Field() + material: str = Field(example="PLA") + density: float = Field(example=1.24) class ExternalMaterialsFile(BaseModel): __root__: list[ExternalMaterial] -class ExternalDB: - filaments: list[ExternalFilament] - materials: list[ExternalMaterial] - - def __init__(self) -> None: - """Initialize the ExternalDB.""" - self.filaments = [] - self.materials = [] - - -_externaldb = ExternalDB() - - def get_external_db_url() -> str: """Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL.""" return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL).strip("/") + "/" @@ -87,31 +69,46 @@ async def _download_file(url: str) -> bytes: return response.read() -def _parse_filaments_from_bytes(data: bytes) -> list[ExternalFilament]: +def _parse_filaments_from_bytes(data: bytes) -> ExternalFilamentsFile: """Parse a bytes string into a list of ExternalFilament objects.""" - return ExternalFilamentsFile.parse_raw(data).__root__ + return ExternalFilamentsFile.parse_raw(data) -def _parse_materials_from_bytes(data: bytes) -> list[ExternalMaterial]: +def _parse_materials_from_bytes(data: bytes) -> ExternalMaterialsFile: """Parse a bytes string into a list of ExternalMaterial objects.""" - return ExternalMaterialsFile.parse_raw(data).__root__ + return ExternalMaterialsFile.parse_raw(data) -def get_external_db() -> ExternalDB: - """Get the external database.""" - return _externaldb +def _write_to_local_cache(filename: str, data: bytes) -> None: + """Write data to the local cache.""" + filecache.update_file(filename, data) + + +def get_filaments_file() -> Path: + """Get the path to the filaments file.""" + return filecache.get_file("filaments.json") + + +def get_materials_file() -> Path: + """Get the path to the materials file.""" + return filecache.get_file("materials.json") async def _sync() -> None: logger.info("Syncing external DB.") url = get_external_db_url() - _externaldb.filaments = _parse_filaments_from_bytes(await _download_file(url + "filaments.json")) - _externaldb.materials = _parse_materials_from_bytes(await _download_file(url + "materials.json")) + + filaments = _parse_filaments_from_bytes(await _download_file(url + "filaments.json")) + materials = _parse_materials_from_bytes(await _download_file(url + "materials.json")) + + _write_to_local_cache("filaments.json", filaments.json().encode()) + _write_to_local_cache("materials.json", materials.json().encode()) + logger.info( "External DB synced. Filaments: %d, Materials: %d", - len(_externaldb.filaments), - len(_externaldb.materials), + len(filaments.__root__), + len(materials.__root__), ) @@ -121,6 +118,10 @@ def schedule_tasks(scheduler: Scheduler) -> None: Args: scheduler: The scheduler to use for scheduling tasks. """ + if len(get_external_db_url().strip()) == 0: + logger.info("External DB URL is empty. Skipping sync.") + return + logger.info("Scheduling external DB sync.") # Run once on startup diff --git a/spoolman/filecache.py b/spoolman/filecache.py new file mode 100644 index 0000000..5318bfa --- /dev/null +++ b/spoolman/filecache.py @@ -0,0 +1,30 @@ +"""A file-based cache system for reading/writing files.""" + +from pathlib import Path + +from spoolman.env import get_data_dir + + +def _get_cache_dir() -> Path: + """Get the cache directory.""" + return get_data_dir() / "cache" + + +def get_file(name: str) -> Path: + """Get the path to a file.""" + return _get_cache_dir() / name + + +def update_file(name: str, data: bytes) -> None: + """Update a file if it differs from the given data.""" + path = get_file(name) + if path.exists() and path.read_bytes() == data: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def get_file_contents(name: str) -> bytes: + """Get the contents of a file.""" + path = get_file(name) + return path.read_bytes()