} />}
+ Title={({ collapsed }) => }
/>
)}
Footer={() => }
>
+ {IS_DEV && (
+
+ 🚀 DEV BUILD - Connected to Unraid PostgreSQL
+
+ )}
= ({ children
theme={{
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
token: {
- colorPrimary: "#dc7734",
+ // DEV VERSION: Teal/cyan theme to distinguish from production orange
+ colorPrimary: "#0891b2",
+ colorInfo: "#0891b2",
},
}}
>
diff --git a/client/src/pages/filaments/create.tsx b/client/src/pages/filaments/create.tsx
index f046f05..29da712 100644
--- a/client/src/pages/filaments/create.tsx
+++ b/client/src/pages/filaments/create.tsx
@@ -127,9 +127,9 @@ export const FilamentCreate: React.FC {
- // Invalidate filament cache so the select options refresh
- await invalidate({
- resource: "filament",
- invalidates: ["list"],
- });
+ // Refetch filament list so the select options include the new filament
+ await refetchFilaments();
// Select the newly created filament
form.setFieldValue("filament_id", filament.id);
setIsFilamentModalOpen(false);
diff --git a/client/src/pages/spools/functions.tsx b/client/src/pages/spools/functions.tsx
index b2e5881..24e1e3a 100644
--- a/client/src/pages/spools/functions.tsx
+++ b/client/src/pages/spools/functions.tsx
@@ -190,6 +190,7 @@ export function useGetFilamentSelectOptions() {
internalSelectOptions: filamentSelectInternal,
externalSelectOptions: filamentSelectExternal,
allExternalFilaments: externalFilaments.data,
+ refetch: internalFilaments.refetch,
};
}
diff --git a/client/src/utils/queryExternalDB.ts b/client/src/utils/queryExternalDB.ts
index 856cd28..1401d42 100644
--- a/client/src/utils/queryExternalDB.ts
+++ b/client/src/utils/queryExternalDB.ts
@@ -22,25 +22,33 @@ export enum Pattern {
SPARKLE = "sparkle",
}
+export enum ExternalSource {
+ SPOOLMANDB = "spoolmandb",
+ FILAMENT_PROFILES_3D = "3dfilamentprofiles",
+}
+
export interface ExternalFilament {
id: string;
manufacturer: string;
name: string;
material: string;
density: number;
- weight: number;
+ weight?: number;
spool_weight?: number;
spool_type?: SpoolType;
diameter: number;
color_hex?: string;
color_hexes?: string[];
extruder_temp?: number;
+ extruder_temp_max?: number;
bed_temp?: number;
+ bed_temp_max?: number;
finish?: Finish;
multi_color_direction?: MultiColorDirection;
pattern?: Pattern;
translucent: boolean;
glow: boolean;
+ source?: ExternalSource;
}
export interface ExternalMaterial {
diff --git a/codex.md b/codex.md
new file mode 100644
index 0000000..055dc48
--- /dev/null
+++ b/codex.md
@@ -0,0 +1,39 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+- `spoolman/` holds the FastAPI backend, SQLAlchemy models, and API routes (`spoolman/api/v1/`).
+- `client/` is the React + TypeScript UI (Vite), with pages under `client/src/pages/` and shared components under `client/src/components/`.
+- `migrations/` contains Alembic migrations; versions live in `migrations/versions/`.
+- `tests_integration/` holds pytest integration tests and Docker Compose configs for multiple databases.
+- `scripts/` provides install/start helpers; `Dockerfile` and `entrypoint.sh` support container builds.
+
+## Build, Test, and Development Commands
+- `pdm install` installs backend dependencies (including dev tools).
+- `pdm run app` starts the API server (`uvicorn spoolman.main:app`).
+- `pdm run itest` runs integration tests across all databases.
+- `pdm run docs` generates API documentation.
+- `python tests_integration/run.py sqlite postgres` runs tests for selected DBs.
+- `cd client && npm ci` installs UI deps; `npm run dev` starts the UI dev server; `npm run build` creates a production build.
+- `bash scripts/install.sh` bootstraps a local install; `bash scripts/start.sh` runs using `.env`.
+
+## Coding Style & Naming Conventions
+- Python: Black + Ruff, 120-char lines (`pyproject.toml`). Prefer async patterns in backend code.
+- TypeScript/React: follow existing conventions (pages in lowercase file names, shared components in `PascalCase`).
+- Keep naming aligned with existing modules (e.g., `spool`, `filament`, `vendor`).
+
+## Testing Guidelines
+- Tests use `pytest` with async support; files follow `test_*.py` and functions start with `test_`.
+- Integration tests are the primary suite; run via `pdm run itest` or `python tests_integration/run.py`.
+
+## Database Migrations
+- Update models in `spoolman/database/models.py`.
+- Start the server once, then generate a migration: `pdm run alembic revision -m "desc" --autogenerate`.
+- Review and format migrations with Black/Ruff before committing.
+
+## Commit & Pull Request Guidelines
+- Follow commit history conventions (e.g., `feat:`, `docs:`, `fix:`) and keep messages concise.
+- PRs should include a short summary, testing performed, and screenshots for UI changes.
+- Link relevant issues where applicable.
+
+## Configuration Tips
+- Copy `.env.example` to `.env` for local runs; key variables include `SPOOLMAN_DB_TYPE`, `SPOOLMAN_PORT`, and `VITE_APIURL`.
diff --git a/spoolman/externaldb.py b/spoolman/externaldb.py
index f0bfb3c..6d04629 100644
--- a/spoolman/externaldb.py
+++ b/spoolman/externaldb.py
@@ -6,7 +6,7 @@ import os
from collections.abc import Iterator
from enum import Enum
from pathlib import Path
-from typing import Optional
+from typing import Any, Optional
from urllib.parse import urljoin
import hishel
@@ -20,8 +20,16 @@ logger = logging.getLogger(__name__)
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
+DEFAULT_3DFP_URL = "https://raw.githubusercontent.com/jklewa/filament-profiles-data/main/"
DEFAULT_SYNC_INTERVAL = 3600
+
+class ExternalSource(Enum):
+ """External database sources."""
+
+ SPOOLMANDB = "spoolmandb"
+ FILAMENT_PROFILES_3D = "3dfilamentprofiles"
+
controller = hishel.Controller(allow_stale=True)
try:
cache_path = get_cache_dir() / "hishel"
@@ -62,7 +70,7 @@ class ExternalFilament(BaseModel):
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
material: str = Field(description="Filament material.", examples=["PLA"])
density: float = Field(description="Density in g/cm3.", examples=[1.23])
- weight: float = Field(description="Net weight of a single spool.", examples=[1000])
+ weight: Optional[float] = Field(default=None, description="Net weight of a single spool.", examples=[1000])
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", examples=[140])
spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
diameter: float = Field(description="Filament in mm.", examples=[1.75])
@@ -77,7 +85,9 @@ class ExternalFilament(BaseModel):
examples=[["2c3232", "5f5f5f"]],
)
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
+ extruder_temp_max: Optional[int] = Field(default=None, description="Max extruder temp in °C.", examples=[220])
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
+ bed_temp_max: Optional[int] = Field(default=None, description="Max bed temp in °C.", examples=[60])
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
multi_color_direction: Optional[MultiColorDirection] = Field(
default=None,
@@ -87,6 +97,10 @@ class ExternalFilament(BaseModel):
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
+ source: ExternalSource = Field(
+ default=ExternalSource.SPOOLMANDB,
+ description="External database source this filament came from.",
+ )
class ExternalFilamentsFile(RootModel):
@@ -120,11 +134,146 @@ class ExternalMaterialsFile(RootModel):
return self.root[index]
+# 3dfilamentprofiles.com data models
+class FilamentProperties3DFP(BaseModel):
+ """Properties object from 3dfilamentprofiles."""
+
+ temp_min: Optional[int] = None
+ temp_max: Optional[int] = None
+ bed_temp_min: Optional[int] = None
+ bed_temp_max: Optional[int] = None
+ spool_weight: Optional[float] = None
+ flow_ratio: Optional[float] = None
+
+
+class Filament3DFP(BaseModel):
+ """Raw filament data from 3dfilamentprofiles.com."""
+
+ id: int
+ brand_name: str
+ material: str
+ material_type: Optional[str] = None
+ color: str
+ rgb: Optional[str] = None
+ properties: Optional[dict[str, Any]] = None
+
+
+class FilamentsFile3DFP(BaseModel):
+ """The filaments.json file from 3dfilamentprofiles."""
+
+ filaments: list[Filament3DFP]
+
+
+# Material density defaults (used when 3dfilamentprofiles doesn't provide density)
+MATERIAL_DENSITIES: dict[str, float] = {
+ "PLA": 1.24,
+ "ABS": 1.04,
+ "PETG": 1.27,
+ "NYLON": 1.52,
+ "TPU": 1.21,
+ "PC": 1.3,
+ "WOOD": 1.28,
+ "CF": 1.3,
+ "PC/ABS": 1.19,
+ "HIPS": 1.03,
+ "PVA": 1.23,
+ "ASA": 1.05,
+ "PP": 0.9,
+ "POM": 1.4,
+ "PMMA": 1.18,
+ "FPE": 2.16,
+}
+
+
+def _get_density_for_material(material: str) -> float:
+ """Get density for a material, defaulting to PLA's density if unknown."""
+ material_upper = material.upper()
+ for key, density in MATERIAL_DENSITIES.items():
+ if key in material_upper:
+ return density
+ return 1.24 # Default to PLA density
+
+
+def _transform_3dfp_to_external(filament: Filament3DFP) -> ExternalFilament:
+ """Transform a 3dfilamentprofiles filament to ExternalFilament format."""
+ # Build name from material_type and color
+ name_parts = []
+ if filament.material_type:
+ name_parts.append(filament.material_type)
+ name_parts.append(filament.color)
+ name = " ".join(name_parts)
+
+ # Handle color hex (3dfp includes # prefix, SpoolmanDB doesn't)
+ color_hex = None
+ if filament.rgb:
+ color_hex = filament.rgb.lstrip("#").lower()
+
+ # Extract properties
+ props = filament.properties or {}
+ extruder_temp = props.get("temp_min")
+ extruder_temp_max = props.get("temp_max")
+ bed_temp = props.get("bed_temp_min")
+ bed_temp_max = props.get("bed_temp_max")
+ spool_weight = props.get("spool_weight")
+
+ # Determine finish from material_type
+ finish = None
+ if filament.material_type:
+ material_type_lower = filament.material_type.lower()
+ if "matte" in material_type_lower:
+ finish = Finish.MATTE
+ elif "glossy" in material_type_lower or "silk" in material_type_lower:
+ finish = Finish.GLOSSY
+
+ # Determine pattern from material_type
+ pattern = None
+ if filament.material_type:
+ material_type_lower = filament.material_type.lower()
+ if "marble" in material_type_lower:
+ pattern = Pattern.MARBLE
+ elif "sparkle" in material_type_lower or "glitter" in material_type_lower:
+ pattern = Pattern.SPARKLE
+
+ return ExternalFilament(
+ id=f"3dfp_{filament.id}",
+ manufacturer=filament.brand_name,
+ name=name,
+ material=filament.material,
+ density=_get_density_for_material(filament.material),
+ weight=None, # 3dfp doesn't provide filament weight
+ spool_weight=spool_weight,
+ spool_type=None,
+ diameter=1.75, # Default, 3dfp doesn't provide diameter
+ color_hex=color_hex,
+ color_hexes=None,
+ extruder_temp=extruder_temp,
+ extruder_temp_max=extruder_temp_max,
+ bed_temp=bed_temp,
+ bed_temp_max=bed_temp_max,
+ finish=finish,
+ multi_color_direction=None,
+ pattern=pattern,
+ translucent=False,
+ glow=False,
+ source=ExternalSource.FILAMENT_PROFILES_3D,
+ )
+
+
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)
+def get_3dfp_url() -> str:
+ """Get the 3dfilamentprofiles URL from environment variables."""
+ return os.getenv("EXTERNAL_DB_3DFP_URL", DEFAULT_3DFP_URL)
+
+
+def is_3dfp_enabled() -> bool:
+ """Check if 3dfilamentprofiles sync is enabled."""
+ return os.getenv("EXTERNAL_DB_3DFP_ENABLED", "true").lower() in ("true", "1", "yes")
+
+
def get_external_db_sync_interval() -> int:
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
@@ -166,22 +315,70 @@ def get_materials_file() -> Path:
return filecache.get_file("materials.json")
+def _parse_3dfp_filaments(data: bytes) -> list[Filament3DFP]:
+ """Parse 3dfilamentprofiles JSON data."""
+ try:
+ parsed = FilamentsFile3DFP.model_validate_json(data)
+ return parsed.filaments
+ except Exception:
+ # Fallback: try parsing as raw list
+ import json
+
+ raw = json.loads(data)
+ if isinstance(raw, dict) and "filaments" in raw:
+ return [Filament3DFP.model_validate(f) for f in raw["filaments"]]
+ return []
+
+
async def _sync() -> None:
logger.info("Syncing external DB.")
- url = get_external_db_url()
+ all_filaments: list[ExternalFilament] = []
- filaments = _parse_filaments_from_bytes(await _download_file(urljoin(url, "filaments.json")))
- materials = _parse_materials_from_bytes(await _download_file(urljoin(url, "materials.json")))
+ # Sync SpoolmanDB
+ spoolmandb_url = get_external_db_url()
+ if spoolmandb_url.strip():
+ try:
+ spoolmandb_filaments = _parse_filaments_from_bytes(
+ await _download_file(urljoin(spoolmandb_url, "filaments.json"))
+ )
+ # Mark all SpoolmanDB filaments with source
+ for filament in spoolmandb_filaments.root:
+ filament.source = ExternalSource.SPOOLMANDB
+ all_filaments.extend(spoolmandb_filaments.root)
+ logger.info("SpoolmanDB synced: %d filaments", len(spoolmandb_filaments.root))
+ except Exception as e:
+ logger.warning("Failed to sync SpoolmanDB: %s", e)
- _write_to_local_cache("filaments.json", filaments.json().encode())
- _write_to_local_cache("materials.json", materials.json().encode())
+ # Sync 3dfilamentprofiles
+ if is_3dfp_enabled():
+ url_3dfp = get_3dfp_url()
+ if url_3dfp.strip():
+ try:
+ raw_data = await _download_file(urljoin(url_3dfp, "sample-filaments.json"))
+ filaments_3dfp = _parse_3dfp_filaments(raw_data)
+ transformed = [_transform_3dfp_to_external(f) for f in filaments_3dfp]
+ all_filaments.extend(transformed)
+ logger.info("3dfilamentprofiles synced: %d filaments", len(transformed))
+ except Exception as e:
+ logger.warning("Failed to sync 3dfilamentprofiles: %s", e)
- logger.info(
- "External DB synced. Filaments: %d, Materials: %d",
- len(filaments.root),
- len(materials.root),
- )
+ # Write combined filaments
+ combined = ExternalFilamentsFile(root=all_filaments)
+ _write_to_local_cache("filaments.json", combined.model_dump_json().encode())
+
+ # Materials only from SpoolmanDB
+ if spoolmandb_url.strip():
+ try:
+ materials = _parse_materials_from_bytes(
+ await _download_file(urljoin(spoolmandb_url, "materials.json"))
+ )
+ _write_to_local_cache("materials.json", materials.model_dump_json().encode())
+ logger.info("Materials synced: %d", len(materials.root))
+ except Exception as e:
+ logger.warning("Failed to sync materials: %s", e)
+
+ logger.info("External DB sync complete. Total filaments: %d", len(all_filaments))
def schedule_tasks(scheduler: Scheduler) -> None: