feat: Add 3dfilamentprofiles.com integration (Issue #7)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

- Backend: Added ExternalSource enum, Filament3DFP model, and transform function
- Backend: Updated _sync() to fetch from both SpoolmanDB and 3dfilamentprofiles
- Frontend: Added source tabs to FilamentImportModal (All/SpoolmanDB/3DFP)
- Frontend: Updated ExternalFilament interface with source and temp range fields
- Frontend: Updated filament import handler to use temp range fields

Also includes:
- Dev mode styling (teal theme, DEV badge and banner)
- Translation keys for source filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 21:03:25 -06:00
parent 6d0ecec068
commit 671d3bd3d1
11 changed files with 447 additions and 41 deletions

View File

@@ -132,7 +132,9 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
| 4 | Layout Max-Width | ✅ DONE | | 4 | Layout Max-Width | ✅ DONE |
| 5 | Temperature Ranges | ✅ DONE | | 5 | Temperature Ranges | ✅ DONE |
| 6 | Spool Adjustment History | ✅ DONE | | 6 | Spool Adjustment History | ✅ DONE |
| 7 | 3dfilamentprofiles Integration | Open | | 7 | 3dfilamentprofiles Integration | ✅ DONE |
| 8 | (see Gitea #8) | ✅ DONE |
| 9 | Hierarchical Locations (Room/Bin) | Open |
### Issue #1 Complete - Inline Creation Modals ### Issue #1 Complete - Inline Creation Modals
@@ -213,3 +215,73 @@ Frontend:
- `client/src/pages/spools/model.tsx` - Added `ISpoolAdjustment` interface - `client/src/pages/spools/model.tsx` - Added `ISpoolAdjustment` interface
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table - `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
- `client/public/locales/en/common.json` - Translation keys for history table - `client/public/locales/en/common.json` - Translation keys for history table
### Issue #7 Complete - 3dfilamentprofiles Integration
Added 3dfilamentprofiles.com as a second external database source alongside SpoolmanDB.
Backend (`spoolman/externaldb.py`):
- Added `ExternalSource` enum (SPOOLMANDB, FILAMENT_PROFILES_3D)
- Added `Filament3DFP` model for raw 3dfp data
- Added `_transform_3dfp_to_external()` to convert 3dfp format to ExternalFilament
- Updated `_sync()` to fetch from both sources and merge results
- Environment variables: `EXTERNAL_DB_3DFP_URL`, `EXTERNAL_DB_3DFP_ENABLED`
Frontend:
- `client/src/utils/queryExternalDB.ts` - Added `ExternalSource` enum, `source` field, temp range fields
- `client/src/components/filamentImportModal.tsx` - Added tabs to filter by source (All/SpoolmanDB/3DFP)
- `client/src/pages/filaments/create.tsx` - Updated import handler for temp range fields
Data mapping (3dfp → Spoolman):
- `brand_name``manufacturer`
- `color` + `material_type``name`
- `rgb``color_hex` (strip # prefix)
- `properties.temp_min/max``extruder_temp/extruder_temp_max`
- `properties.bed_temp_min/max``bed_temp/bed_temp_max`
- Diameter defaults to 1.75mm (3dfp doesn't provide)
- Density from material lookup table
---
## Local Development Setup
To run locally against the Unraid PostgreSQL database:
```bash
# Create virtualenv and install deps
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[postgres]"
# Start backend (reads .env file)
source .venv/bin/activate
export $(grep -v '^#' .env | xargs)
uvicorn spoolman.main:app --host 0.0.0.0 --port 7912
# Start frontend (separate terminal)
cd client
VITE_APIURL=http://localhost:7912/api/v1 npm run dev
```
Access at http://localhost:5173
### Dev Mode Styling
Files modified for dev version visual distinction:
- `client/src/contexts/color-mode/index.tsx` - Teal primary color (#0891b2)
- `client/src/components/layout.tsx` - "DEV BUILD" banner and badge
Set `IS_DEV = false` in layout.tsx to disable dev styling.
---
## Known Issues / TODO
### Filament Select Not Refreshing After Inline Creation
When creating a filament via the inline modal on spool create page, the select dropdown doesn't immediately show the new filament with its name (shows ID instead).
Attempted fixes:
- Added `refetch` from `useGetFilamentSelectOptions` hook
- Called `await refetchFilaments()` before setting form value
Still not working reliably. May need to investigate react-query cache invalidation timing or add the new option manually to the select options array.

View File

@@ -276,6 +276,8 @@
"filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!", "filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
"import_external": "Import from External", "import_external": "Import from External",
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.", "import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.",
"all_sources": "All Sources",
"select_filament": "Select a filament...",
"created_success": "Created filament \"{{name}}\"", "created_success": "Created filament \"{{name}}\"",
"vendor_placeholder": "Select a manufacturer", "vendor_placeholder": "Select a manufacturer",
"name_placeholder": "E.g. Matte Black", "name_placeholder": "E.g. Matte Black",

View File

@@ -1,9 +1,10 @@
import { useTranslate } from "@refinedev/core"; import { useTranslate } from "@refinedev/core";
import { Form, Modal, Select } from "antd"; import { Form, Modal, Select, Tabs } from "antd";
import { useMemo, useState } from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { formatFilamentLabel } from "../pages/spools/functions"; import { formatFilamentLabel } from "../pages/spools/functions";
import { searchMatches } from "../utils/filtering"; import { searchMatches } from "../utils/filtering";
import { ExternalFilament, useGetExternalDBFilaments } from "../utils/queryExternalDB"; import { ExternalFilament, ExternalSource, useGetExternalDBFilaments } from "../utils/queryExternalDB";
export function FilamentImportModal(props: { export function FilamentImportModal(props: {
isOpen: boolean; isOpen: boolean;
@@ -12,12 +13,25 @@ export function FilamentImportModal(props: {
}) { }) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const t = useTranslate(); const t = useTranslate();
const [selectedSource, setSelectedSource] = useState<ExternalSource | "all">("all");
const externalFilaments = useGetExternalDBFilaments(); const externalFilaments = useGetExternalDBFilaments();
const filamentOptions =
externalFilaments.data?.map((item) => { // Filter and format filaments based on selected source
const filamentOptions = useMemo(() => {
const filtered = externalFilaments.data?.filter((item) => {
if (selectedSource === "all") return true;
return item.source === selectedSource;
}) ?? [];
const options = filtered.map((item) => {
// Add source indicator to label if showing all
const sourceIndicator = selectedSource === "all" && item.source === ExternalSource.FILAMENT_PROFILES_3D
? "[3DFP] "
: "";
return { return {
label: formatFilamentLabel( label: sourceIndicator + formatFilamentLabel(
item.name, item.name,
item.diameter, item.diameter,
item.manufacturer, item.manufacturer,
@@ -28,8 +42,23 @@ export function FilamentImportModal(props: {
value: item.id, value: item.id,
item: item, item: item,
}; };
}) ?? []; });
filamentOptions.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
options.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
return options;
}, [externalFilaments.data, selectedSource]);
// Count filaments by source
const counts = useMemo(() => {
const spoolmandb = externalFilaments.data?.filter(f => f.source === ExternalSource.SPOOLMANDB || !f.source).length ?? 0;
const threeDFP = externalFilaments.data?.filter(f => f.source === ExternalSource.FILAMENT_PROFILES_3D).length ?? 0;
return { spoolmandb, threeDFP, total: spoolmandb + threeDFP };
}, [externalFilaments.data]);
const handleSourceChange = (key: string) => {
setSelectedSource(key as ExternalSource | "all");
form.resetFields(["filament"]);
};
return ( return (
<Modal <Modal
@@ -37,7 +66,36 @@ export function FilamentImportModal(props: {
open={props.isOpen} open={props.isOpen}
onOk={() => form.submit()} onOk={() => form.submit()}
onCancel={() => props.onClose()} onCancel={() => props.onClose()}
width={600}
> >
<p>
<Trans
i18nKey={"filament.form.import_external_description"}
components={{
br: <br />,
}}
/>
</p>
<Tabs
activeKey={selectedSource}
onChange={handleSourceChange}
items={[
{
key: "all",
label: `${t("filament.form.all_sources")} (${counts.total})`,
},
{
key: ExternalSource.SPOOLMANDB,
label: `SpoolmanDB (${counts.spoolmandb})`,
},
{
key: ExternalSource.FILAMENT_PROFILES_3D,
label: `3D Filament Profiles (${counts.threeDFP})`,
},
]}
/>
<Form <Form
layout="vertical" layout="vertical"
form={form} form={form}
@@ -49,21 +107,15 @@ export function FilamentImportModal(props: {
props.onImport(filament); props.onImport(filament);
props.onClose(); props.onClose();
form.resetFields(); form.resetFields();
setSelectedSource("all");
}} }}
> >
<p>
<Trans
i18nKey={"filament.form.import_external_description"}
components={{
br: <br />,
}}
/>
</p>
<Form.Item name="filament" rules={[{ required: true }]}> <Form.Item name="filament" rules={[{ required: true }]}>
<Select <Select
options={filamentOptions} options={filamentOptions}
showSearch showSearch
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)} filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
placeholder={t("filament.form.select_filament")}
/> />
</Form.Item> </Form.Item>
</Form> </Form>

View File

@@ -1,12 +1,15 @@
import { ThemedLayout, ThemedSider, ThemedTitle } from "@refinedev/antd"; import { ThemedLayout, ThemedSider, ThemedTitle } from "@refinedev/antd";
import { useTranslate } from "@refinedev/core"; import { useTranslate } from "@refinedev/core";
import { Button } from "antd"; import { Badge, Button } from "antd";
import { Footer } from "antd/es/layout/layout"; import { Footer } from "antd/es/layout/layout";
import Logo from "../icon.svg?react"; import Logo from "../icon.svg?react";
import { getBasePath } from "../utils/url"; import { getBasePath } from "../utils/url";
import { Header } from "./header"; import { Header } from "./header";
import { Version } from "./version"; import { Version } from "./version";
// DEV VERSION indicator
const IS_DEV = true;
const SpoolmanFooter: React.FC = () => { const SpoolmanFooter: React.FC = () => {
const t = useTranslate(); const t = useTranslate();
@@ -50,17 +53,50 @@ interface SpoolmanLayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
const DevTitle: React.FC<{ collapsed: boolean }> = ({ collapsed }) => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ThemedTitle collapsed={collapsed} text="Spoolman" icon={<Logo />} />
{IS_DEV && !collapsed && (
<Badge
count="DEV"
style={{
backgroundColor: "#0891b2",
fontSize: 10,
fontWeight: "bold",
marginLeft: -8,
marginTop: -20,
}}
/>
)}
</div>
);
export const SpoolmanLayout: React.FC<SpoolmanLayoutProps> = ({ children }) => ( export const SpoolmanLayout: React.FC<SpoolmanLayoutProps> = ({ children }) => (
<ThemedLayout <ThemedLayout
Header={() => <Header sticky />} Header={() => <Header sticky />}
Sider={() => ( Sider={() => (
<ThemedSider <ThemedSider
fixed fixed
Title={({ collapsed }) => <ThemedTitle collapsed={collapsed} text="Spoolman" icon={<Logo />} />} Title={({ collapsed }) => <DevTitle collapsed={collapsed} />}
/> />
)} )}
Footer={() => <SpoolmanFooter />} Footer={() => <SpoolmanFooter />}
> >
{IS_DEV && (
<div
style={{
background: "linear-gradient(90deg, #0891b2 0%, #06b6d4 50%, #0891b2 100%)",
color: "white",
textAlign: "center",
padding: "4px 0",
fontSize: 12,
fontWeight: "bold",
letterSpacing: 1,
}}
>
🚀 DEV BUILD - Connected to Unraid PostgreSQL
</div>
)}
<div <div
style={{ style={{
maxWidth: 1400, maxWidth: 1400,

View File

@@ -41,7 +41,9 @@ export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children
theme={{ theme={{
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm, algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
token: { token: {
colorPrimary: "#dc7734", // DEV VERSION: Teal/cyan theme to distinguish from production orange
colorPrimary: "#0891b2",
colorInfo: "#0891b2",
}, },
}} }}
> >

View File

@@ -127,9 +127,9 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
multi_color_hexes: filament.color_hexes?.join(",") || undefined, multi_color_hexes: filament.color_hexes?.join(",") || undefined,
multi_color_direction: filament.multi_color_direction, multi_color_direction: filament.multi_color_direction,
settings_extruder_temp_min: filament.extruder_temp || undefined, settings_extruder_temp_min: filament.extruder_temp || undefined,
settings_extruder_temp_max: filament.extruder_temp || undefined, settings_extruder_temp_max: filament.extruder_temp_max || filament.extruder_temp || undefined,
settings_bed_temp_min: filament.bed_temp || undefined, settings_bed_temp_min: filament.bed_temp || undefined,
settings_bed_temp_max: filament.bed_temp || undefined, settings_bed_temp_max: filament.bed_temp_max || filament.bed_temp || undefined,
}); });
}; };

View File

@@ -1,6 +1,6 @@
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"; import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd"; import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd"; import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -33,7 +33,6 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.spool); const extraFields = useGetFields(EntityType.spool);
const currency = useCurrency(); const currency = useCurrency();
const invalidate = useInvalidate();
const [isFilamentModalOpen, setIsFilamentModalOpen] = useState(false); const [isFilamentModalOpen, setIsFilamentModalOpen] = useState(false);
const { form, formProps, formLoading, onFinish, redirect } = useForm< const { form, formProps, formLoading, onFinish, redirect } = useForm<
@@ -82,14 +81,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
internalSelectOptions, internalSelectOptions,
externalSelectOptions, externalSelectOptions,
allExternalFilaments, allExternalFilaments,
refetch: refetchFilaments,
} = useGetFilamentSelectOptions(); } = useGetFilamentSelectOptions();
const handleFilamentCreated = async (filament: IFilament) => { const handleFilamentCreated = async (filament: IFilament) => {
// Invalidate filament cache so the select options refresh // Refetch filament list so the select options include the new filament
await invalidate({ await refetchFilaments();
resource: "filament",
invalidates: ["list"],
});
// Select the newly created filament // Select the newly created filament
form.setFieldValue("filament_id", filament.id); form.setFieldValue("filament_id", filament.id);
setIsFilamentModalOpen(false); setIsFilamentModalOpen(false);

View File

@@ -190,6 +190,7 @@ export function useGetFilamentSelectOptions() {
internalSelectOptions: filamentSelectInternal, internalSelectOptions: filamentSelectInternal,
externalSelectOptions: filamentSelectExternal, externalSelectOptions: filamentSelectExternal,
allExternalFilaments: externalFilaments.data, allExternalFilaments: externalFilaments.data,
refetch: internalFilaments.refetch,
}; };
} }

View File

@@ -22,25 +22,33 @@ export enum Pattern {
SPARKLE = "sparkle", SPARKLE = "sparkle",
} }
export enum ExternalSource {
SPOOLMANDB = "spoolmandb",
FILAMENT_PROFILES_3D = "3dfilamentprofiles",
}
export interface ExternalFilament { export interface ExternalFilament {
id: string; id: string;
manufacturer: string; manufacturer: string;
name: string; name: string;
material: string; material: string;
density: number; density: number;
weight: number; weight?: number;
spool_weight?: number; spool_weight?: number;
spool_type?: SpoolType; spool_type?: SpoolType;
diameter: number; diameter: number;
color_hex?: string; color_hex?: string;
color_hexes?: string[]; color_hexes?: string[];
extruder_temp?: number; extruder_temp?: number;
extruder_temp_max?: number;
bed_temp?: number; bed_temp?: number;
bed_temp_max?: number;
finish?: Finish; finish?: Finish;
multi_color_direction?: MultiColorDirection; multi_color_direction?: MultiColorDirection;
pattern?: Pattern; pattern?: Pattern;
translucent: boolean; translucent: boolean;
glow: boolean; glow: boolean;
source?: ExternalSource;
} }
export interface ExternalMaterial { export interface ExternalMaterial {

39
codex.md Normal file
View File

@@ -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`.

View File

@@ -6,7 +6,7 @@ import os
from collections.abc import Iterator from collections.abc import Iterator
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
from urllib.parse import urljoin from urllib.parse import urljoin
import hishel import hishel
@@ -20,8 +20,16 @@ logger = logging.getLogger(__name__)
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/" 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 DEFAULT_SYNC_INTERVAL = 3600
class ExternalSource(Enum):
"""External database sources."""
SPOOLMANDB = "spoolmandb"
FILAMENT_PROFILES_3D = "3dfilamentprofiles"
controller = hishel.Controller(allow_stale=True) controller = hishel.Controller(allow_stale=True)
try: try:
cache_path = get_cache_dir() / "hishel" cache_path = get_cache_dir() / "hishel"
@@ -62,7 +70,7 @@ class ExternalFilament(BaseModel):
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"]) name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
material: str = Field(description="Filament material.", examples=["PLA"]) material: str = Field(description="Filament material.", examples=["PLA"])
density: float = Field(description="Density in g/cm3.", examples=[1.23]) 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_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]) spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
diameter: float = Field(description="Filament in mm.", examples=[1.75]) diameter: float = Field(description="Filament in mm.", examples=[1.75])
@@ -77,7 +85,9 @@ class ExternalFilament(BaseModel):
examples=[["2c3232", "5f5f5f"]], examples=[["2c3232", "5f5f5f"]],
) )
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210]) 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: 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]) finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
multi_color_direction: Optional[MultiColorDirection] = Field( multi_color_direction: Optional[MultiColorDirection] = Field(
default=None, default=None,
@@ -87,6 +97,10 @@ class ExternalFilament(BaseModel):
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE]) 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.") 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.") 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): class ExternalFilamentsFile(RootModel):
@@ -120,11 +134,146 @@ class ExternalMaterialsFile(RootModel):
return self.root[index] 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: def get_external_db_url() -> str:
"""Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL.""" """Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
return os.getenv("EXTERNAL_DB_URL", 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: def get_external_db_sync_interval() -> int:
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL.""" """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)) 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") 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: async def _sync() -> None:
logger.info("Syncing external DB.") 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"))) # Sync SpoolmanDB
materials = _parse_materials_from_bytes(await _download_file(urljoin(url, "materials.json"))) spoolmandb_url = get_external_db_url()
if spoolmandb_url.strip():
_write_to_local_cache("filaments.json", filaments.json().encode()) try:
_write_to_local_cache("materials.json", materials.json().encode()) spoolmandb_filaments = _parse_filaments_from_bytes(
await _download_file(urljoin(spoolmandb_url, "filaments.json"))
logger.info(
"External DB synced. Filaments: %d, Materials: %d",
len(filaments.root),
len(materials.root),
) )
# 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)
# 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)
# 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: def schedule_tasks(scheduler: Scheduler) -> None: