Compare commits

..

113 commits

Author SHA1 Message Date
dlawler489
d6fe7ec3ff Merge CI check fix
All checks were successful
build-and-push / docker (push) Successful in 39s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:44:43 +10:00
dlawler489
baa5ba4382 Build the CI checks instead of bind-mounting them
The job runs inside a container, so -v "$PWD" mounted a path the Docker
daemon doesn't have and npm ci found no lockfile. Dockerfile.check sends
the repo as a build context instead. The gate itself worked: the image
build was skipped rather than pushing something unchecked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:44:13 +10:00
dlawler489
a1977e3efd Merge ESLint and CI checks
Some checks failed
build-and-push / docker (push) Failing after 11s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:41:09 +10:00
dlawler489
ac88a247ae Add ESLint, and run it with the tests in CI
A variable declared inside a try block but read after it shipped and broke
collection checks for a day; tests can't reach that path and node --check
only parses. ESLint flags it as no-undef. Wired into npm run lint / check
and into CI before the image build, so a failure stops the build.

Three findings fixed on the way: an unread argument, and two values
assigned then immediately replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:40:43 +10:00
dlawler489
517574720e Merge watch check fix
All checks were successful
build-and-push / docker (push) Successful in 15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:48:45 +10:00
dlawler489
ca0d0d52f5 Fix a watch check stopping after one model
withFiles was declared inside the try block but read by the wait after it,
so every check threw ReferenceError once it had imported a single model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:48:45 +10:00
dlawler489
4956eabbf4 Merge data path fix
All checks were successful
build-and-push / docker (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:00:29 +10:00
dlawler489
9e92be8407 Pin the data paths in compose, and find the files via the container
A stack manager that deploys from its own directory resolved ./data to an
empty folder, so Postgres initialised a blank database and the install
looked wiped. The compose defaults are absolute now, and survive a sync
that deletes an untracked .env.

The backup script asks the running container where /app/uploads comes from
rather than trusting a setting, refuses to mirror an empty folder, and
won't write a dump of an empty library over backups that have one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:59:13 +10:00
dlawler489
0844c9f86a Merge backup settings fix
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:49:33 +10:00
dlawler489
217671a5ef Read .env settings tolerantly, and check the files folder first
The backup script missed DATA_DIR when the line carried an export prefix,
quotes, padding or a CR, and silently fell back to ./data — dumping first
and only then discovering there was nothing to mirror. It now strips all
that, prints the paths it's using, and stops before writing anything if
the files folder isn't there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:49:33 +10:00
dlawler489
22645f139a Merge nightly backup script
All checks were successful
build-and-push / docker (push) Successful in 15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:44:43 +10:00
dlawler489
bc71c19a40 Keep backups out of git
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:19:26 +10:00
dlawler489
a5bb4dc33d Add a nightly backup script
A dated pg_dump plus an rsync mirror of the model files, with the stack
running. The dump is read back with pg_restore -l inside the container
before it replaces the day's file, so a bad run leaves older backups
alone. Older dumps are pruned after KEEP_DAYS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:19:21 +10:00
dlawler489
01a6688900 Merge host folders for data
All checks were successful
build-and-push / docker (push) Successful in 17s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:10:34 +10:00
dlawler489
6c10cf6b37 Let the data folders be set per install, database separately
DATA_DIR takes the files and PGDATA_DIR the database, so the database can
stay on a filesystem that supports ownership when the files live on a disk
that doesn't. The example .env points both at /Volumes/Data/printhive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:01:43 +10:00
dlawler489
985b81d1de Keep data in host folders instead of Docker named volumes
The database and files now live under ./data (DATA_DIR to move them), so
they can be seen, backed up and restored without going through Docker.
tools/volumes-to-folders.sh copies an existing install's named volumes
across; it only copies, leaving the volumes in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:58:28 +10:00
dlawler489
d5fa67d9e7 Merge MakerWorld anti-robot handling
All checks were successful
build-and-push / docker (push) Successful in 55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:48:36 +10:00
dlawler489
08d9bdabb1 Note the anti-robot check in the README
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:45:09 +10:00
dlawler489
ef2093b73f Say when MakerWorld is waiting for you to prove you're not a robot
The challenge was detected but treated as an ordinary download failure, so
the message didn't say that only you can clear it. Now it stops the run,
is remembered centrally, and watches skip downloads while it stands —
metadata still imports, and models stay partial for "Get files". The
Watched page shows a banner with a link to MakerWorld and an "I've solved
it" button; a successful download clears it too. Detection also covers a
challenge page returned where JSON was expected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:44:54 +10:00
dlawler489
bbbaf8fa7d Merge chamber camera view
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:08:01 +10:00
dlawler489
a91c7b4bf7 Show the chamber camera on the printer card
A Camera button on each card opens the printer's chamber view, served
through Printhive so the access code stays on the server. One connection
per printer however many people are watching, dropped as soon as the last
viewer closes it; the latest frame goes to a new viewer straight away.
Nothing is recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:07:22 +10:00
dlawler489
a8fd112257 Merge chamber camera connection and test
All checks were successful
build-and-push / docker (push) Successful in 17s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:04:32 +10:00
dlawler489
a442d31dc7 Add a chamber camera connection and a test for it
P1 and A1 series printers serve JPEG frames on port 6000 over TLS, behind
the same access code. This adds the connection, a frame reader, and
POST /api/printers/:id/camera/test, which waits for one picture and says
what arrived — enough to find out whether the camera answers before any
viewer is built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 11:47:09 +10:00
dlawler489
f7f7b21d36 Merge active filament display
All checks were successful
build-and-push / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:31:53 +10:00
dlawler489
1aca9a6c01 Show which filament is feeding each printer, with a tube from spool to toolhead
The live connection reads tray_now (unit × 4 + slot, 254 external, 255
none) and works out the unit by id or, failing that, by position — so the
A2L's AMS lite numbered 16 is handled. Cards say 'Using' (or 'Loaded' when
not printing) with the filament's name and slot, mark the slot in the list,
and draw a tube in its colour from that spool to the toolhead, with a
moving flow while printing. The external spool is drawn when it's in use.
Debug reports include tray_now and tray_tar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:27:57 +10:00
dlawler489
859554e345 Merge page and model addresses
All checks were successful
build-and-push / docker (push) Successful in 15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:21:26 +10:00
dlawler489
f3e0a645a2 Keep the open model in the address too
Opening a model adds /model/<id> to the address (#collection/12/model/34),
so a refresh or bookmark reopens it and Back closes it. Moving from one
model to the next doesn't add Back steps, and closing takes back the step
opening added. A model that's gone is dropped from the address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:21:03 +10:00
dlawler489
2aff27cbdd Keep the page you're on in the address
Each page has an address (#filament, #printers, #collection/12), so a refresh
or bookmark returns to it and Back/Forward move between pages. The search
and filter bar is hidden on the Filament page, where it did nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:19:13 +10:00
dlawler489
ff2b0bc11f Merge live status fixes and untagged spool naming
All checks were successful
build-and-push / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:55:16 +10:00
dlawler489
73176cc41d Call untagged spools by their material and reported colour, not a Bambu colour name
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:55:16 +10:00
dlawler489
88988237ae Don't let a busy printer's reports hold back spool sync; hide placeholder chamber temps
A printer mid-print reports every second, and each report restarted the
five-second wait, so a printing printer's spools never synced. Only a change
in what's loaded restarts it now. Printers without a chamber sensor report
5 °C, which is no longer shown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:50:54 +10:00
dlawler489
4daf15d390 Merge live printer status
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:46:50 +10:00
dlawler489
13b5e550a3 Show live printer status over a standing read-only connection
Each printer now has one MQTT connection that stays open, merging the
partial reports printers send while printing. Cards poll every 3 seconds
(paused while the tab is hidden) and show state, file, progress with time
left and finish time, layer, nozzle/bed/chamber temperatures, speed
setting, and print error and HMS codes. A spool change syncs the Filament
page and the card's drawing within seconds; a full report is requested
every 10 minutes as a backstop, replacing the 10-minute probe loop.

"Test & sync" uses the open connection rather than making a second one
(?debug=1 still makes a short separate one). Connections are reconciled
when printers are added, edited or removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:30:13 +10:00
dlawler489
c7d4194243 Merge loaded filament on printer cards
All checks were successful
build-and-push / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:23:22 +10:00
dlawler489
c2bae07e54 Show each printer's loaded filament on its card
Each sync keeps what the printer reported, slot by slot (material, colour,
empty, tagged — nothing that identifies a spool). The card's drawing colours
in the first AMS unit's spools, dashes empty slots, and lists every slot and
the external holder underneath with its colour name. "Test & sync" redraws
the card with what it just read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:21:03 +10:00
dlawler489
99e0f3d245 Merge AMS unit labels
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:18:56 +10:00
dlawler489
fdb50d45e5 Label AMS units by their range, so the A2L's AMS lite isn't 'AMS 17'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:14:03 +10:00
dlawler489
697ac0ccc8 Merge the Printers page and spool RFID tags
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:03:17 +10:00
dlawler489
e98edaa9ed Add a Printers page for any number of printers, and show spools' RFID tags
Printers move out of Settings into their own table and page: each has an
optional nickname (the model is shown when blank), a model from the Bambu
lineup with a line drawing that follows the selection, an AMS type
suggested from the model, and its address, serial and access code. Access
codes are never returned to the browser. The printer set up in Settings is
migrated automatically and its settings rows removed.

Background sync covers every printer. Grams left are filled in only from an
AMS that measures (AMS, AMS 2 Pro, AMS HT), never from an AMS lite.

Spools now show their RFID tag ID, product code and filament code, and say
which printer they're loaded in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:00:43 +10:00
dlawler489
1c19ac565e Merge automatic spools from printer reports
All checks were successful
build-and-push / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:41:31 +10:00
dlawler489
48f564c648 Add spools automatically from what the printer has loaded
Every Bambu spool reports an RFID identity — a spool id and a tag id, both
unique — so a printer's report can now keep the Filament page in step: a new
tagged spool is created, named and coded from the bundled filament list; one
already known is moved to its current slot; one you added by hand before the
printer was connected is adopted rather than duplicated; and one the printer no
longer reports is marked as taken out. It runs on "Save & test connection" and
every ten minutes in the background, still only reading from the printer.

Untagged spools are listed, not stored: without a tag, one spool of a colour is
indistinguishable from the next, so guessing would mean duplicates.

The planning is a pure function, tested against the cases that would cause
duplicates. The database side was exercised separately against a real
Postgres: three successive reports created, moved and unloaded spools as
intended, and a deliberate duplicate was refused by the partial unique index.

The amount left isn't taken from the printer yet: an AMS lite only reports full
or empty, so it's left blank until printers can say which kind of AMS they have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:39:30 +10:00
dlawler489
99d6bdf06c Merge spool RFID identity and the raw-report diagnostic
All checks were successful
build-and-push / docker (push) Successful in 15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:31:50 +10:00
dlawler489
88251ccd8a Keep a spool's RFID identity, and add a raw-report diagnostic
The tray reader kept material, colour, profile and amount but dropped the
spool's RFID fields, so the printer test couldn't have shown a filament's
serial even if the printer sent one. It now keeps tray_uuid (the spool),
tag_uid (the tag) and tray_id_name where the firmware provides it, and turns
the all-zero values sent for untagged spools into "none" rather than a fake id.

The printer test also takes ?debug=1, which returns one loaded tray exactly as
the printer sent it, to see which fields a given printer and firmware provide
before designing around them. It's filament data; no credentials are in it.

Also notes that an AMS lite's "remain" is full-or-empty rather than a
measurement, which the next change needs to account for per printer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:26:47 +10:00
dlawler489
7c893e50bd Merge read-only Bambu Lab printer connection
All checks were successful
build-and-push / docker (push) Successful in 31s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:16:36 +10:00
dlawler489
93130967af Connect to a Bambu Lab printer, read-only
Settings → Printer takes the printer's address, serial number and access code
(the code stored as a secret, like the other tokens). "Save & test connection"
connects to the printer's own MQTT broker on port 8883, subscribes to its
reports, asks once for a full report, and listens for a few seconds — then
says what it heard: its state, and the filament in each AMS slot or on the
external spool holder, with how much is left where the printer knows.

Nothing sent to the printer changes what it is doing; the only thing published
is the request for a full report. If the printer is in cloud mode and treats
even that as a write, the test says it received nothing rather than failing
silently. The access code never leaves the server.

Adds mqtt as a runtime dependency — server.js now loads it at startup, so the
lockfile has to travel with this change.

Not verified against a real printer yet: the printer's ports answer on the
network, but no connection has been made with its credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:15:15 +10:00
dlawler489
122f9d53ce Merge any-colour slot assignment order
All checks were successful
build-and-push / docker (push) Successful in 15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:40:37 +10:00
dlawler489
1581f767fa Fill "any colour" slots last, not first
Found on the live library: setting slot 2 of a four-colour bookmark to "any
colour" made it take the one Jade White spool, leaving slot 4 — which asks for
#FFFFFF — with nothing, and the verdict stuck at "needs 3 colours" when it
should have improved to 2.

The assignment sorts candidate pairs by colour distance and scored a colourless
slot 0, a perfect match, so it always claimed a spool ahead of a slot that
needed that exact colour. Colourless slots now sort last and take whatever is
left over.

Fixed in both the server and the browser copy, which mirror each other, and
pinned by a test built from the reported case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:27:58 +10:00
dlawler489
e8b68e1149 Merge editable filament slots, with an any-colour option
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:21:04 +10:00
dlawler489
8f3e63585c Let a slot ask for any colour
Some prints don't care about the shade, and some change with the order. Each
slot in the editor now has an "any colour" box: ticking it greys out the swatch
and saves the slot without a colour, which the matcher fills from any spool of
that material. A slot added by hand starts this way, since adding one usually
means you care about the material rather than the shade.

An unparseable colour already became "any colour" rather than being refused;
that stays, and is now covered by tests along with the shapes the checkbox
actually sends — null, empty string, and no colour key at all.

Worth knowing when reading the printable count: ticking "any colour" makes a
model easier to satisfy, so the count now partly reflects your decisions rather
than only what the project files ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:19:47 +10:00
dlawler489
eaf1bd93d5 Let the required colours be edited on the model page
The slot list comes from the project file, which is sometimes wrong and
sometimes not what you intend to print. "Edit slots" turns it into fields:
material and colour per slot, with slots you can add or drop. Saved slots are
marked as yours and shown with a "your list" badge, and "Use the project's
list" throws the override away and re-reads the 3MF.

Edits have to outlive the background reader, which is the part that would fail
silently: the reader only picks up models with no status, and reread() now
skips 'edited' rather than nulling everything. Verified on a real model — the
edit survived a reread plus a full worker pass, and the revert restored the
project's own four slots exactly.

Input cleaning moved out of the route into lib/filaments.js so it can be
tested: a slot with no material is dropped, and a colour that isn't a proper
hex becomes "any colour of that material" rather than being rejected.

Not verified: none of this has been seen rendered — the local preview browser
has no session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:15:48 +10:00
dlawler489
c81dc68c0b Merge colour-naming fixes
All checks were successful
build-and-push / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:12:25 +10:00
dlawler489
08c6dcc432 Name a colour after the everyday filament, not the first match
Seventeen PLA filaments are #000000 and eleven are #FFFFFF, so slot 1 of a
bookmark came out as "Aero Black" — true, but not what anyone calls it. Naming
now prefers Basic, then Matte, before specialist types.

Also puts a space between the "none" badge and the nearest-spool hint, which
were running together as "nonenearest: Jade White".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:10:18 +10:00
dlawler489
d6ad49d9cb Merge colour-accurate filament matching and the Bambu filament picker
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:06:40 +10:00
dlawler489
2635a1e9f0 Pick spools from the maker's filament list, and name slot colours
Adding a spool no longer means typing a hex code: choose "PLA Basic — Jade
White (10100)" and the material, colour name, swatch and filament code fill
themselves. Slot colours on the model page are named where they match, so a
slot reads "#586579 · Basic Blue Gray" instead of bare hex.

The filament code is stored per spool. It isn't decoration — the AMS reports
filament IDs, so this is what a later read-only printer connection will match a
loaded tray against.

db/bambu-filaments.json holds Bambu Lab's 325 filaments (material, type, colour
name, code, colours), included by kind permission of 3dfilamentprofiles.com,
whose terms otherwise reserve the data. The permission and source are recorded
in the file itself as well as the README, so nobody reuses it by accident.

Naming uses a much tighter threshold than matching (12 against 50): identifying
a colour and substituting for one are different questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:01:09 +10:00
dlawler489
7b018aefdb Match slots by colour, not just material
Counting slots fixed one half of the problem; this fixes the other. A slot is
now filled by a spool of the same material whose colour is close to what the
slot asks for, one spool per slot, best matches assigned first. So four PLA
slots in black, blue-grey, grey and white are not satisfied by four spools of
white — only the white slot is.

Closeness is redmean distance with a tolerance of 50, which treats genuinely
near shades as the same colour while keeping #000000 and #161616 (about 66
apart) distinct. Slot colours arrive as #RRGGBB and #RRGGBBAA — the alpha
channel is parsed off rather than making the colour unreadable.

The model page now lists every slot: its colour, which spool fills it, and the
nearest thing on the shelf when nothing does.

Two things this fixes in code I wrote an hour ago: renderDetailPrintable still
called a function the rewrite removed, so opening any model would have thrown;
and the README claimed colours weren't matched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:47:18 +10:00
dlawler489
8814e79669 Count slots, not materials: four colours need four spools
Reported from the library: a four-colour AMS bookmark showed as "printable now"
against a single spool of Jade White. The matching collapsed the project's four
slots into the set of materials it used — {PLA} — and then asked only whether a
PLA spool existed. A sixteen-colour model would have passed the same way.

Ignoring colour is right: printing a bookmark in red instead of white still
prints. Ignoring slot count is not: four slots need four spools loaded. Matching
now counts slots per material and reports the shortfall — "needs 3 more PLA",
and on the model page "Needs 4 × PLA. You have 1 of 4 PLA."

The tests carried the same mistake, asserting that one spool satisfied a
multi-slot project, so they are rewritten around the reported case.

Also documents the feature in the README, which had no entry for it at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:41:40 +10:00
dlawler489
ecdcafc967 Merge printable-now badges, section and filter
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:54:09 +10:00
dlawler489
5f708e24f5 Show what's printable with the filament you have
Three placements: a pill beside the sellable badge on each card, a "Printable
now" section between Source and License on the model page, and a toolbar filter
for "printable now" / "missing filament".

Only the 101 models whose slicer project names its filaments can answer this,
so the rest say so plainly rather than being reported as unprintable. Colour is
not matched — a different colour still prints.

Also fixes a bug from the filament page itself: 'filament' was missing from
SPECIAL_SCOPES, so any data refresh while that page was open (after an AI run,
say) decided the collection had vanished and dropped you back to All Models.

Caveat: the client's printability() mirrors lib/filaments.js by hand. The server
side has tests; this copy cannot easily get them, because app.js touches DOM
globals at load — so the two can drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:46:54 +10:00
dlawler489
5b559326e3 Merge filament inventory and per-model filament reading
All checks were successful
build-and-push / docker (push) Successful in 32s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:32:52 +10:00
dlawler489
ba3bb415bb Track the filament you have, and what each model asks for
A Bambu Studio or OrcaSlicer 3MF names the filaments its plates use, but
reading that means shelling out to Python, so it can't be done per page load.
A background pass — in the same loop as thumbnails and fingerprints — reads it
once per model into models.filaments, which turns "what can I print now" into
a comparison of two lists.

Adds a Filament page for the spools on hand: material, colour, brand and grams
left, edited in place. Matching is on material only; a project's exact colours
would make almost nothing printable, and a different colour still prints.

Measured on the dev library: 10 models read with no errors, the two Bambu
projects resolving to four PLA colours each, and no plate images written
because the reader skips --images and queues behind anything interactive.

Not done yet: nothing in the library view uses this — no "printable now" badge
or filter — and the Filament page itself has not been seen rendered, because
the local preview browser has no session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:18:54 +10:00
dlawler489
c0c5e3300a Add the dev server config the preview tool launches
All checks were successful
build-and-push / docker (push) Successful in 32s
Names the project's dev command and port (npm start, 3100) so the in-app
browser preview can start Printhive without being told how each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:34:57 +10:00
dlawler489
341f8c5df1 Merge per-watch file downloading
All checks were successful
build-and-push / docker (push) Successful in 24s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:18:55 +10:00
dlawler489
32299da05e Let a watch download files as models arrive
Checks have imported metadata only since MakerWorld refused bulk downloads.
Today's measurement changed that picture: it refuses bursts a few seconds
apart, but 67 models downloaded in a row about 20 seconds apart without one
refusal. So downloading during a check is viable — at the right pace.

Per watch, off by default: "Files: on/off" on the row, a checkbox when adding
one. With it on, a check spaces downloads 20 seconds apart instead of 3, and
stops after two refusals in a row, coming back shortly rather than collecting
refusals for the whole batch. A model whose files were refused still lands as
'partial', so "Get files" can finish it later.

Auto-imports now get the same AI treatment a manual run does — tags, a clearer
name where the site's is boilerplate, and collections to review — rather than
tags alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:18:55 +10:00
dlawler489
8fbcc6a2b8 Merge the corrected download-limit note
All checks were successful
build-and-push / docker (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:11:50 +10:00
dlawler489
8adac1018f Correct what the README says about MakerWorld's download limit
It claimed an account may only download "a couple in a row". Measured today:
67 models downloaded in a row, spaced about 20 seconds apart, with no refusal
at all. The refusals happen when downloads are fired a few seconds apart, as a
watch check used to do — so it is a rate limit on bursts, not an allowance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:53:32 +10:00
dlawler489
b2078690bd Merge deadlines for downloads
All checks were successful
build-and-push / docker (push) Successful in 39s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:52:12 +10:00
dlawler489
341731a946 Give downloads a deadline instead of hanging for ever
Neither the FlareSolverr call nor the CDN fetch set a timeout, so a stalled
connection hung the request indefinitely: the button sat on "Downloading…"
with nothing to reset it and no error to show. FlareSolverr calls now give up
15s after the deadline FlareSolverr itself was given (and a timeout counts as
retryable, like other transport failures), and a CDN download gives up after
three minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:50:48 +10:00
dlawler489
27e1caf1a6 Merge one lock order for collection rows
All checks were successful
build-and-push / docker (push) Successful in 21s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:44:05 +10:00
dlawler489
9ce95a6521 Take collection rows in one order too
The tag paths are sorted now, but two writers of model_projects still iterated
in caller order: findOrCreateProject over the models it attaches, and
setProjects over a model's collections. Two of either running at once could
take the same rows in opposite orders — the same shape as the tag deadlock, one
table across.

Preventative: unlike the tag case, no failure from these was ever observed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:19:32 +10:00
dlawler489
d2dd67c311 Merge the failure list into the page for real
All checks were successful
build-and-push / docker (push) Successful in 26s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:10:26 +10:00
dlawler489
07da33a191 Actually put the failure list in the page
The block was built into a `failed` variable and then never interpolated, so
runHtml computed it and threw it away. Two deploys changed nothing visible
because of this: the API carried the failures and the panel fetched them, but
the template never referenced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:09:27 +10:00
dlawler489
099e6d24b4 Merge the tagging deadlock fix
All checks were successful
build-and-push / docker (push) Successful in 22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:56:28 +10:00
dlawler489
8a2ad5785c Stop tagging deadlocking, and retry a transaction that loses one
Two transactions tagging overlapping models could each hold a tag row the other
wanted. tagId used ON CONFLICT DO UPDATE, which rewrites the row and therefore
write-locks it even though the name never changes, and each caller iterated in
whatever order it happened to have. That is what killed "Mushroom Clicker"
during AI run 21, while the collection watcher was importing and rule-tagging
the same tags.

tagId no longer rewrites unchanged rows, and applyAiResult, replaceRuleTags and
setTags all tag in sorted order, so locks are taken in the same sequence every
time. db.transaction retries a deadlock victim up to three times as well, which
covers the paths this doesn't — the victim rolled back and changed nothing, so
running it again is safe.

Measured: the old pattern deadlocks on demand (40P01), and the same collision
run 48 times through the new code fails zero times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:56:28 +10:00
dlawler489
4036139901 Merge failure list rendering for any run with failures
All checks were successful
build-and-push / docker (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:52:40 +10:00
dlawler489
96b3fbb1bb Draw the failure list for any run that has one, not just the newest
The failures now reach the API, but the page could still not show them: the AI
panel fetches detail for one run — the running one, else the newest — and
renderAiLive passes detail only to that run, so every other run is drawn with
nothing. Run 21's single failure stayed invisible for exactly this reason,
even after the previous fix.

Details are now fetched for any listed run with failures and keyed by run id.
Usually that is no extra request, and at most a handful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:49:12 +10:00
dlawler489
1f22dd0189 Merge visible AI run failures and retry for finished runs
All checks were successful
build-and-push / docker (push) Successful in 27s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:08:08 +10:00
dlawler489
b0054876ff Name every model an AI run failed on, and let a finished run retry them
A run's detail only carried the last dozen items, so a model that failed early
scrolled out of reach: run 21 reported "1 failed" with no way to learn which
model or why. The detail now carries every failed item, listed in full.

"Retry failed" existed but could never appear: it needs a run that is finished
with failures, and such a run is 'done', which neither canResume nor resume()
accepted. Both now do; resume() already no-ops when nothing is pending, so a
clean run is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:08:08 +10:00
dlawler489
96497d79a8 Merge inline watch errors in place of alert()
All checks were successful
build-and-push / docker (push) Successful in 24s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:04:16 +10:00
dlawler489
fc5e81006b Show a watch's failure on its row, not in an alert()
A failed "Get files" popped a browser alert: jarring, and the message is gone
the moment you dismiss it — which matters here, because the message explains
MakerWorld's download limit. It now appears on the watch's row until the next
attempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:55:41 +10:00
dlawler489
be8776bbce Merge Watched-page refresh, full file-less list, and single collection read
All checks were successful
build-and-push / docker (push) Successful in 30s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:36:41 +10:00
dlawler489
e7c65e28a1 Keep the Watched page current, and reach every model waiting for files
Three things the live run showed:

- The page only polled while a check was already running when it rendered, so
  a page opened between checks showed stale numbers for ever — it missed a
  check started by the schedule or another tab. It now refreshes either way.
- Only the five most recent items were listed, so most models still without
  files had no "Get files" button anywhere. They're now in a collapsible list.
- Adding a watch read the whole collection twice: once to create it, once in
  the first check. Measured on a 535-model collection: the add still takes 52s,
  but the first import now lands 6s later instead of another read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:34:59 +10:00
dlawler489
0962216196 Merge metadata-feed watching for MakerWorld collections
All checks were successful
build-and-push / docker (push) Successful in 22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:12:52 +10:00
dlawler489
81b6016d63 Watch collections as a metadata feed, not a downloader
MakerWorld allows an account only a couple of downloads in a row, so a check
that tried to fetch files left most models empty. A check now imports each
model's page — name, description, picture, licence — and marks it 'partial';
"Get files" pulls the files for the ones you actually want to print.

Verified against a public collection: 11 models imported with descriptions and
thumbnails, no files fetched, no MakerWorld token needed for the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:07:47 +10:00
dlawler489
bf8e95da97 Merge honest handling of MakerWorld's download limit
All checks were successful
build-and-push / docker (push) Successful in 29s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:53:36 +10:00
dlawler489
a65c9d047f Don't retry MakerWorld's anti-bot refusal
Measured against the live site: FlareSolverr reports no challenge on that
endpoint and an unauthenticated call gets a normal reply in 3s, so this is
MakerWorld limiting an account to a couple of downloads in a row, not a
Cloudflare page. Two models retried three times each still came back empty —
the retry only made every failure a minute slower.

Keeps the classification, so a refusal is named for what it is and the model
is flagged as still without files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:53:36 +10:00
dlawler489
239654be13 Pausing a watch stops the batch it's in the middle of
Pause only prevented the next check: the batch already running kept importing
its remaining models, which is the opposite of what the button suggests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:50:56 +10:00
dlawler489
cd95890296 Offer "Get files" for any import that lost its files
Models imported before 'partial' existed are stored as 'imported' with an
error, so they'd never show the button. Mark them on startup, and key the
button on the stored error rather than the status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:44:13 +10:00
dlawler489
11fe5c25aa Merge MakerWorld anti-bot retry and partial-import flagging
All checks were successful
build-and-push / docker (push) Successful in 24s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:37:20 +10:00
dlawler489
a313e0e224 Retry MakerWorld's anti-bot check, and flag imports that got no files
A run of downloads gets challenged by Cloudflare partway through: the answer
comes back as an ordinary reply, so nothing retried it and the model landed
with only its metadata — looking, on the watch page, like a success.

resolveDownload now recognises a challenge and gives FlareSolverr's browser a
couple more goes; what still fails is recorded as 'partial' and shown with its
reason and a "Get files" button, which clears the flag once the files arrive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:34:04 +10:00
dlawler489
18f15cf3b4 Merge MakerWorld collection watching
All checks were successful
build-and-push / docker (push) Successful in 24s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:22:47 +10:00
dlawler489
4c54f76ae3 Read whole collections, and never check one twice at once
- A collection page lists only its first ~20 models. Checks now page through the
  same load-more call the page uses (offset + a shared seed, 64 at a time, up to
  ~640 models), so a watch sees the whole collection.
- A private collection read without a valid token comes back as a placeholder
  (id -1, no models), which was being treated as an empty collection. It now
  reports the token problem instead.
- Adding a watch starts a check, and "Check now" could start a second one
  alongside it, importing models twice. One check per collection at a time now,
  and a check interrupted by a restart no longer looks like it's still running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:58:09 +10:00
dlawler489
0df38c83c7 Watch a MakerWorld collection and import models added to it
A watch reads the collection page through FlareSolverr carrying your token, so
private collections work, and imports the models it hasn't seen into a Printhive
collection of the same name (optionally AI-tagging them). Checks run on your
chosen interval, a few models at a time with pauses between, and each design is
remembered so nothing is imported twice. A refused token stops the check and the
Watched page offers to update it; models removed on MakerWorld stay here.

The import endpoint's work moved into importFromUrl() so watches reuse it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:53:35 +10:00
dlawler489
66b62cbb1e Merge sturdier AI replies with clearer errors
All checks were successful
build-and-push / docker (push) Successful in 24s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:29:28 +10:00
dlawler489
848496a205 Sturdier AI replies: format example, more retries, and errors that show the reply
The Red panda kept failing with "The AI reply wasn't the JSON asked for", and
nothing showed what came back. Now:
- the prompt includes an example object with just the requested fields
  (DeepSeek's JSON mode can otherwise send back an empty reply)
- JSON is found inside code fences, notes, or braces after it
- a bad reply is asked again up to twice: with more room when it was cut off,
  and the last time without JSON mode; max_tokens starts at 1500
- the error says whether the reply was empty or cut off and quotes its start
- tokens spent on failed attempts count towards the run's cost

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:26:42 +10:00
dlawler489
6a3e23359f Merge emoji-safe text for AI requests
All checks were successful
build-and-push / docker (push) Successful in 25s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:22:06 +10:00
dlawler489
2bbdd5d5c3 Don't split emoji when shortening text for the AI
Descriptions sent to the AI are cut to 800 characters. When the cut landed
inside an emoji (stored as two UTF-16 halves), the request carried half of it,
which DeepSeek rejects: "unexpected end of hex escape". Text is now cut before
the emoji, anything already broken in the library is replaced before sending,
and the tags, names, collection ideas and descriptions read back are cut the
same safe way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:48:07 +10:00
dlawler489
57c514c1af Merge MakerWorld download errors and token prompt
All checks were successful
build-and-push / docker (push) Successful in 28s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:33:10 +10:00
dlawler489
4a88d66e90 Ask for a fresh MakerWorld token when MakerWorld refuses it, then retry
When a download fails because MakerWorld refused (or is missing) the auth
token, the error carries reason makerworld_token. The import and "Download
files from source" then open an "Update your MakerWorld token" prompt with
steps to copy the token cookie; saving it retries the download straight away
and says if MakerWorld still refuses it. A missing FlareSolverr URL is reported
separately, since a token alone won't help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:33:10 +10:00
dlawler489
28a95ee330 Say why MakerWorld files didn't download instead of importing silently without them
MakerWorld answers an expired or missing auth token with {"error": "Please log
in to download models."} and no download link, which was skipped without a
word, so imports and "Download files from source" just ended with no files.
Now the reason comes back: missing settings, a refused token (probably expired),
no printable files on the page, or a failed download. The import screen shows
it, and the Settings hint explains where the token comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:17:46 +10:00
dlawler489
f80078d7ed Merge AI collection suggestions and reply retry
All checks were successful
build-and-push / docker (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:52:41 +10:00
dlawler489
9d7a978f03 AI collection suggestions, pick-what-to-do runs, and a retry for bad replies
- Runs can now do any mix of: tags & descriptions, clearer names, and
  collection suggestions. A run without tags doesn't mark models as tagged.
- Collection ideas reuse your collections and earlier ideas where they fit and
  wait on the AI page, grouped by name, until you create/add or dismiss them.
  Dismissed ideas aren't suggested for that model again; spelling variants
  group together under the capitalised spelling.
- A reply that isn't the JSON asked for is asked again once; both requests
  count towards the run's tokens and cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:52:41 +10:00
dlawler489
2076717447 Merge AI renaming: clearer names for models with unclear ones, with undo
All checks were successful
build-and-push / docker (push) Successful in 26s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:30:35 +10:00
dlawler489
2506f4dce9 Let AI runs give models with unclear names a clearer one, with undo
A run option asks the AI for a clearer name only when the current one is messy
(file-name leftovers, typos, site boilerplate). The previous name is kept, the
run feed shows what was renamed, and the model page offers Undo. Giving a model
a name yourself clears the undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:26:29 +10:00
dlawler489
b902ea91d6 Merge phase 4 fixes: tag wrapping, model page buttons, search refresh after AI tagging
All checks were successful
build-and-push / docker (push) Successful in 28s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:05:21 +10:00
dlawler489
4458963aa4 Fix tags splitting across lines and squeezed model buttons; refresh search after AI tagging
The model page's tag list wasn't a wrapping row, so the last tag could break in
half, and four action buttons crushed onto two lines each. AI runs also never
woke the background worker, so new AI tags and descriptions weren't searchable
until something else changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:39:41 +10:00
dlawler489
c7c39f99fa Merge phase 4: AI tags and descriptions, search by meaning, automatic tags
All checks were successful
build-and-push / docker (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:54:03 +10:00
dlawler489
2796fab38c Docker: fail the build if the search model can't be fetched
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:54:03 +10:00
dlawler489
0f49fe8c1a AI tags and descriptions, search by meaning, and automatic tags
- AI tagging through any OpenAI-compatible API: DeepSeek by default, or
  OpenRouter, OpenAI, or a local server (Ollama, LM Studio). Settings → AI
  with presets, prices and "Save & test connection"; the key never leaves
  the server except to the provider.
- Runs over untagged models, all models, or one model from its page: live
  progress, tokens and cost, stop/resume (retrying failures), an optional
  spending limit, and paused rather than resumed after a restart. Adapts to
  models without JSON mode or image input; stops on a rejected key or no
  credit. Tags are only added; descriptions filled only where empty unless
  asked.
- Search by meaning: local bge-small-en-v1.5 embeddings (transformers.js),
  indexed in the background whenever a model's text changes; Words/Meaning
  switch on the search box. Stored as REAL[] in Postgres, no pgvector needed.
- Automatic tags: strict whole-word rules (part types, mechanisms, materials,
  techniques) over names, file names and archive contents, plus your own
  word -> tag rules; on/off (optionally removing its tags) and re-apply.
- Tags remember where they came from (user / rule / ai); edits keep that,
  rule and AI passes never touch your own tags, and the model page marks them.
- Docker: ships the embedding model and only Linux onnxruntime binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:12:58 +10:00
dlawler489
78c225f232 Merge phase 3: slicer hand-off and drag-out
All checks were successful
build-and-push / docker (push) Successful in 2m59s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:36:26 +10:00
dlawler489
01844b4941 Merge phase 2: formats and viewer (archives, plates, filament colours, server thumbnails)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:36:26 +10:00
dlawler489
f401eb4ca4 Merge phase 1: library core (duplicates, trash, notes, search and filters)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:36:26 +10:00
dlawler489
be1facdec3 Drag out from file tabs, plate chips and a Drag out handle; only offer dragging in Chromium browsers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:26:48 +10:00
dlawler489
f3a69c82ec Open models in your slicer and drag files out
- Settings → Slicer: pick Bambu Studio, OrcaSlicer, PrusaSlicer, Cura, a
  custom open-link template, or plain download (remembered per browser)
- Bambu Studio / OrcaSlicer launch via their URL schemes with a 10-minute
  signed download link (/dl/<token>/<name>) that needs no session cookie
- PrusaSlicer and Cura fall back to a download (they can't open our links)
- 3MF projects report which app saved them; opening one in a different
  slicer asks first
- "slicer" action on every file row and archive entry
- Drag model cards (file, or zip of all files) and file/entry rows out to
  the desktop via Chromium's DownloadURL

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:15:37 +10:00
57 changed files with 10302 additions and 170 deletions

11
.claude/launch.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "printhive",
"runtimeExecutable": "npm",
"runtimeArgs": ["start"],
"port": 3100
}
]
}

View file

@ -12,6 +12,8 @@ data
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
Dockerfile Dockerfile
Dockerfile.check
.dockerignore .dockerignore
docker-compose.yml docker-compose.yml
extracted extracted
models

View file

@ -2,5 +2,18 @@
DATABASE_URL=postgres://USER@localhost:5432/stl_library DATABASE_URL=postgres://USER@localhost:5432/stl_library
PORT=3000 PORT=3000
# Docker only: where data lives on the host. Both default to ./data beside
# docker-compose.yml. DATA_DIR takes the model files, thumbnails and previews.
DATA_DIR=/Volumes/Data/printhive
# The database is set separately, because Postgres needs a filesystem with real
# ownership and permissions — leave it on local disk if DATA_DIR is exFAT, FAT
# or a network share.
PGDATA_DIR=/Volumes/Data/printhive/pgdata
# Where tools/backup.sh writes (default ./backups) and how many days of
# database dumps it keeps (default 14). Best on a different disk from DATA_DIR.
BACKUP_DIR=/Volumes/Data/printhive-backups
KEEP_DAYS=14
# Thingiverse import — get a free app token at https://www.thingiverse.com/developers # Thingiverse import — get a free app token at https://www.thingiverse.com/developers
THINGIVERSE_TOKEN= THINGIVERSE_TOKEN=

View file

@ -16,6 +16,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Lint and test
# Built, not mounted: this job runs in a container, so a host bind mount
# of $PWD arrives empty. A failure here stops the image being built.
run: docker build -f Dockerfile.check -t printhive-check .
- name: Log in to the Forgejo container registry - name: Log in to the Forgejo container registry
# REGISTRY_TOKEN = a Forgejo access token with write:package scope, added # REGISTRY_TOKEN = a Forgejo access token with write:package scope, added
# as a repo Actions secret. The auto GITHUB_TOKEN can't push packages. # as a repo Actions secret. The auto GITHUB_TOKEN can't push packages.

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ glb/
tools/venv/ tools/venv/
__pycache__/ __pycache__/
extracted/ extracted/
models/
backups/

View file

@ -18,11 +18,19 @@ RUN python3 -m venv /app/tools/venv \
# Node deps (cached unless package files change). # Node deps (cached unless package files change).
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev \
# semantic search only needs onnxruntime's Linux binaries (saves ~130 MB)
&& rm -rf node_modules/onnxruntime-node/bin/napi-v3/darwin node_modules/onnxruntime-node/bin/napi-v3/win32
# App source. # App source.
COPY . . COPY . .
# The embedding model for semantic search (~34 MB), fetched at build time so the
# server never has to download it.
ENV MODELS_DIR=/app/models
# (No process.exit: forcing an exit while onnxruntime's threads are up aborts node.)
RUN node -e "require('./lib/embeddingModel').embed(['test']).then(() => console.log('embedding model ready'), (err) => { console.error(err); process.exitCode = 1; })"
ENV NODE_ENV=production \ ENV NODE_ENV=production \
PORT=3000 PORT=3000
EXPOSE 3000 EXPOSE 3000

11
Dockerfile.check Normal file
View file

@ -0,0 +1,11 @@
# Lint and tests, run in CI. A build context rather than a bind mount, because
# the CI job runs inside a container of its own — $PWD there means nothing to
# the Docker daemon, so a mounted folder arrives empty.
FROM node:22-bookworm-slim
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run lint && npm test

View file

@ -8,12 +8,14 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **Import from URL**: - **Import from URL**:
- **Thingiverse** — downloads files automatically via the official API (needs a free app token). - **Thingiverse** — downloads files automatically via the official API (needs a free app token).
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models). - **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. - **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. If files can't be downloaded the import says why, and when MakerWorld refuses the token (they expire) Printhive asks for a fresh one and retries.
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually. - **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects. - **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects.
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden. - **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden.
- **Archives as folders** — ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background; browse what's inside and preview or download any file without unpacking. Models inside archives count for duplicate detection. - **Archives as folders** — ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background; browse what's inside and preview or download any file without unpacking. Models inside archives count for duplicate detection.
- **Pictures, PDFs & readmes** — keep them with a model and view them in the app. - **Pictures, PDFs & readmes** — keep them with a model and view them in the app.
- **Open in slicer** — pick your slicer once per browser (Settings → Slicer). Bambu Studio and OrcaSlicer open the file straight from a 10-minute download link (`bambustudioopen://…`, `orcaslicer://open?file=…`), so the server must be reachable at the address the browser uses; Bambu Studio asks before opening files that aren't from MakerWorld. PrusaSlicer (which only accepts Printables/Thingiverse links) and Cura get a normal download, and any other slicer with an open-link can be added as a template. Opening a 3MF project in a different slicer from the one that saved it asks first. Works for files inside archives too.
- **Drag out** — drag a model card (its file, or a zip of all its files) or any file row, including files inside archives, onto your desktop or a folder to save it. Uses Chromium's DownloadURL drag, so it needs Chrome, Edge, Brave or similar.
- **Auto thumbnails** made in the background: an embedded 3MF/plate preview, else a picture that came with the model (or one inside its archive), else a server-side render of the STL/OBJ/3MF; the browser renders FBX/STEP/3DM ones. - **Auto thumbnails** made in the background: an embedded 3MF/plate preview, else a picture that came with the model (or one inside its archive), else a server-side render of the STL/OBJ/3MF; the browser renders FBX/STEP/3DM ones.
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling). - **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
- **Collections** — group models (a model can be in many); rename/delete without losing models. - **Collections** — group models (a model can be in many); rename/delete without losing models.
@ -21,6 +23,12 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **Notes** — free-form notes per model, saved as you type. - **Notes** — free-form notes per model, saved as you type.
- **Search, filters & sorting** — search across names, descriptions, notes, tags, file names and designers; filter by format, source and license; include *or exclude* tags (match any/all); sort and group by source or format. Press <kbd>/</kbd> to search. - **Search, filters & sorting** — search across names, descriptions, notes, tags, file names and designers; filter by format, source and license; include *or exclude* tags (match any/all); sort and group by source or format. Press <kbd>/</kbd> to search.
- **Duplicate finder** — every file gets a SHA-256 content hash plus (for STL/OBJ/3MF) a geometry fingerprint, computed in the background. Models sharing files are grouped as *exact copies* or *same shape* (renamed, re-exported, converted — or mirrored, so check those), with a recommended copy to keep. Keeping one merges the others' tags, collections and notes into it and moves them to the trash; groups can be dismissed as "not duplicates". - **Duplicate finder** — every file gets a SHA-256 content hash plus (for STL/OBJ/3MF) a geometry fingerprint, computed in the background. Models sharing files are grouped as *exact copies* or *same shape* (renamed, re-exported, converted — or mirrored, so check those), with a recommended copy to keep. Keeping one merges the others' tags, collections and notes into it and moves them to the trash; groups can be dismissed as "not duplicates".
- **Watch a MakerWorld collection** — paste a collection link (private ones work, using your MakerWorld token) and Printhive checks it on a schedule you choose, importing models added to it into a collection of the same name here, optionally AI-tagging the new ones. Each check reads the whole collection (paging through MakerWorld's own load-more call, up to ~640 models) and imports up to 15 of the new ones, spaced out, returning shortly after if more are waiting; models you remove on MakerWorld stay in your library. Each watch shows when it last ran, what it imported, and why a check failed — with a one-click token update when MakerWorld refuses your token. A check imports each model's page — name, description, picture, licence — but not its files, so a large collection arrives quickly; models are listed as still without files and you fetch the ones you want with "Get files". MakerWorld refuses downloads fired off in quick succession (a few seconds apart, as a check would), but spacing them out is fine — 67 models in a row downloaded about 20 seconds apart without a single refusal. After a run of downloads MakerWorld may also ask you to prove you're not a robot: the Watched page says so and Printhive stops downloading — checks still import new models, whose files wait — until you solve it on MakerWorld and press "I've solved it".
- **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images.
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page.
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. The slot list can be edited on the model page when the project's own list is wrong or you plan to print it differently — set a slot to **any colour** when the shade doesn't matter, and it's filled by any spool of that material. Edited lists are marked as yours and left alone by the background reader; "Use the project's list" puts it back. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere.
- **Printers** — add as many Bambu Lab printers as you have on the Printers page, each with an optional nickname (the model is shown when it's blank), a model picked from the lineup with a drawing of its shape, its AMS type, and its address, serial number and access code. Printhive stays connected to each printer and its card shows what it's doing live: state, the file printing, progress, time left and finish time, layer, nozzle, bed and chamber temperatures, the speed setting, and any error or HMS codes. The drawing colours in the spools loaded in the AMS, with every slot listed underneath. "Test & sync" asks a printer for a full report straight away. "Camera" shows the chamber camera on a P1 or A1 series printer — about a picture a second, connected only while you're watching and never recorded. The connection only listens — Printhive never sends a printer a command, other than asking for a full report — and access codes stay on your server. Spools a printer reads from their RFID tags are added to the Filament page by themselves, within seconds of going in: named and coded from Bambu's filament list, shown with their tag ID, matched to any you'd already added by hand rather than duplicated, and marked as taken out when they leave the printer. Grams left are filled in from an AMS, AMS 2 Pro or AMS HT; an AMS lite only knows full or empty, so those are left to you. Spools without a tag can't be told apart from one another, so they're listed for you to add by hand.
- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk). - **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked. - **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
@ -47,7 +55,60 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
``` ```
4. Open http://localhost:3000 4. Open http://localhost:3000
Run the unit tests with `npm test`. Run the unit tests with `npm test`, ESLint with `npm run lint`, or both with `npm run check`. CI runs both before
building the image, so a lint error stops a broken image being pushed. The lint rules are deliberately thin — their
job is the class of mistake tests can't catch, chiefly a name that doesn't exist until the line finally runs.
### Where your data lives
With Docker, everything sits in folders on the host — `./data` beside `docker-compose.yml` unless `.env` says
otherwise (`DATA_DIR` for the files, `PGDATA_DIR` for the database):
| Folder | Holds |
|---|---|
| `$PGDATA_DIR` | The database: models, tags, collections, watches, spools, printers, settings (tokens and printer access codes live here) |
| `$DATA_DIR/uploads` | Your model files, as uploaded or downloaded |
| `$DATA_DIR/thumbnails` | Generated pictures, including plate images |
| `$DATA_DIR/glb` | Converted 3D previews for the viewer |
| `$DATA_DIR/extracted` | Files unpacked from archives — a cache |
The database is set apart from the rest because Postgres needs a filesystem with real ownership and permissions —
ext4, xfs, APFS and the like. exFAT, FAT and most network shares won't do, so keep `PGDATA_DIR` on local disk if the
files live on one of those.
A backup needs the database (or a `pg_dump`) and `uploads`; the other three rebuild themselves. Everything else in
the container is replaced on each update, so nothing outside these folders is kept.
### Backups
`tools/backup.sh` takes a dated dump of the database and mirrors the model files, leaving Printhive running:
```bash
./tools/backup.sh
```
It writes to `./backups` unless `BACKUP_DIR` says otherwise: `backups/db/printhive-YYYY-MM-DD.dump` (kept for
`KEEP_DAYS`, 14 by default) and `backups/uploads`, an rsync mirror of the files — matching what's there now,
deletions included, rather than a history. Thumbnails, previews and extracted archives are skipped, as they rebuild.
Each dump is read back with `pg_restore -l` before it replaces the day's file, so a failed run leaves the older
backups alone and exits non-zero.
Nightly, at 3:30am — `crontab -e`, with the path to your own copy:
```
30 3 * * * cd /path/to/printhive && ./tools/backup.sh >> backups/backup.log 2>&1
```
Docker has to be running for it to work, and the backups are best kept on a different disk from `DATA_DIR`.
To restore the database into a running stack:
```bash
docker compose exec -T db pg_restore -U printhive -d printhive --clean --if-exists < backups/db/printhive-2026-01-01.dump
```
Upgrading from a version that used Docker named volumes: stop the stack, run `tools/volumes-to-folders.sh` to copy the
data across, then start it again. The script only copies — the old volumes stay until you remove them yourself.
## Configuration ## Configuration
@ -59,8 +120,19 @@ Settings live in the database (editable in the app's **Settings** menu) and fall
| FlareSolverr URL | `FLARESOLVERR_URL` | Read Cloudflare-protected sites (MakerWorld) | | FlareSolverr URL | `FLARESOLVERR_URL` | Read Cloudflare-protected sites (MakerWorld) |
| MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) | | MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) |
| Printables auth token | `PRINTABLES_TOKEN` | Optional; account-gated Printables files | | Printables auth token | `PRINTABLES_TOKEN` | Optional; account-gated Printables files |
| AI provider | `AI_PROVIDER` | `deepseek` (default), `openrouter`, `openai` or `custom` |
| AI API key | `AI_API_KEY` | Your provider key (not needed for a local server) |
| AI model | `AI_MODEL` | Defaults to `deepseek-flash` for DeepSeek |
| AI API address | `AI_BASE_URL` | Defaults per provider; e.g. `http://localhost:11434/v1` for Ollama |
| AI prices | `AI_PRICE_INPUT`, `AI_PRICE_OUTPUT` | $ per million tokens, only for showing costs (DeepSeek defaults to its peak rates) |
| Send thumbnails | `AI_SEND_IMAGES` | `false` to tag from names and files only |
Printers are added on the **Printers** page instead — as many as you like, each with a nickname, model, AMS type,
address, serial number and access code. Access codes stay on the server. A printer set up in Settings by an earlier
version moves there automatically; the old `PRINTER_*` environment variables are no longer read.
## Notes ## Notes
- Auto-download from MakerWorld/Printables relies on their private/session APIs and can break if they change; failed/partial imports can be recovered with "Download files from source". - Auto-download from MakerWorld/Printables relies on their private/session APIs and can break if they change; failed/partial imports can be recovered with "Download files from source".
- The embedding model for search is downloaded into `./models` on first use locally; the Docker image ships with it. It holds ~150 MB of RAM while in use and is released after 10 idle minutes.
- The commercial-use badge is a best-effort read of the license — always confirm on the source page before selling. - The commercial-use badge is a best-effort read of the license — always confirm on the source page before selling.

334
db/bambu-filaments.json Normal file
View file

@ -0,0 +1,334 @@
{
"source": "https://3dfilamentprofiles.com/filaments/bambu-lab",
"permission": "Copied with the site owner's permission, granted on request 2026-09-17. Their terms otherwise prohibit copying, scraping and redistribution — ask them before reusing this file anywhere else.",
"collected": "2026-09-17",
"count": 325,
"note": "Bambu Lab filaments: material, product type, colour name, Bambu filament code (what the AMS reports), and the colour(s) shown. Multi-colour filaments list each colour; a few entries have no published code.",
"filaments": [
{"material":"ABS","type":"Basic","name":"Azure","code":"40601","colours":["#489FDF"]},
{"material":"ABS","type":"Basic","name":"Bambu Green","code":"40500","colours":["#00AE42"]},
{"material":"ABS","type":"Basic","name":"Beige","code":"40401","colours":["#DFD1A7"]},
{"material":"ABS","type":"Basic","name":"Black","code":"40101","colours":["#000000"]},
{"material":"ABS","type":"Basic","name":"Blue","code":"40600","colours":["#0A2CA5"]},
{"material":"ABS","type":"Basic","name":"Grey","code":"10103","colours":["#8E9089"]},
{"material":"ABS","type":"Basic","name":"Lavender","code":"40701","colours":["#7248BD"]},
{"material":"ABS","type":"Basic","name":"Mint","code":"40501","colours":["#ADF4DC"]},
{"material":"ABS","type":"Basic","name":"Navy Blue","code":"40602","colours":["#0C2340"]},
{"material":"ABS","type":"Basic","name":"Olive","code":"40502","colours":["#789D4A"]},
{"material":"ABS","type":"Basic","name":"Orange","code":"40300","colours":["#FF6A13"]},
{"material":"ABS","type":"Basic","name":"Purple","code":"40700","colours":["#AF1685"]},
{"material":"ABS","type":"Basic","name":"Red","code":"40200","colours":["#D32941"]},
{"material":"ABS","type":"Basic","name":"Silver","code":"40102","colours":["#87909A"]},
{"material":"ABS","type":"Basic","name":"Tangerine Yellow","code":"40402","colours":["#FFC72C"]},
{"material":"ABS","type":"Basic","name":"White","code":"40100","colours":["#FFFFFF"]},
{"material":"ABS","type":"Basic","name":"Yellow","code":"40400","colours":["#FFFF20"]},
{"material":"ABS","type":"GF","name":"Black","code":"41101","colours":["#000000"]},
{"material":"ABS","type":"GF","name":"Blue","code":"41600","colours":["#0C3B95"]},
{"material":"ABS","type":"GF","name":"Gray","code":"41102","colours":["#C6C6C6"]},
{"material":"ABS","type":"GF","name":"Green","code":"41500","colours":["#61BF36"]},
{"material":"ABS","type":"GF","name":"Orange","code":"41300","colours":["#F48438"]},
{"material":"ABS","type":"GF","name":"Red","code":"41200","colours":["#E83100"]},
{"material":"ABS","type":"GF","name":"White","code":"41100","colours":["#FFFFFF"]},
{"material":"ABS","type":"GF","name":"Yellow","code":"41400","colours":["#FFE133"]},
{"material":"ASA","type":"Aero","name":"White","code":"46100","colours":["#EFF2DE"]},
{"material":"ASA","type":"Basic","name":"Black","code":"45101","colours":["#000000"]},
{"material":"ASA","type":"Basic","name":"Blue","code":"45600","colours":["#2140B4"]},
{"material":"ASA","type":"Basic","name":"Gray","code":"45102","colours":["#8A949E"]},
{"material":"ASA","type":"Basic","name":"Green","code":"45500","colours":["#00A6A0"]},
{"material":"ASA","type":"Basic","name":"Red","code":"45200","colours":["#E02928"]},
{"material":"ASA","type":"Basic","name":"White","code":"45100","colours":["#FFFAF2"]},
{"material":"ASA","type":"CF","name":"Black","code":"46101","colours":["#000000"]},
{"material":"HIPS","type":"Support","name":"Support for ABS White","code":"66100","colours":["#FFFFFF"]},
{"material":"PA6","type":"CF","name":"Black","code":"72100","colours":["#000000"]},
{"material":"PA6","type":"GF","name":"Black","code":"72104","colours":["#000000"]},
{"material":"PA6","type":"GF","name":"Blue","code":"72600","colours":["#75AED8"]},
{"material":"PA6","type":"GF","name":"Brown","code":"72800","colours":["#5B492F"]},
{"material":"PA6","type":"GF","name":"Gray","code":"72103","colours":["#353533"]},
{"material":"PA6","type":"GF","name":"Lime","code":"72500","colours":["#C5ED48"]},
{"material":"PA6","type":"GF","name":"Orange","code":"72200","colours":["#FF4800"]},
{"material":"PA6","type":"GF","name":"White","code":"72102","colours":["#EAEAE4"]},
{"material":"PA6","type":"GF","name":"Yellow","code":"72400","colours":["#FFCE00"]},
{"material":"PAHT","type":"CF","name":"Black","code":"70100","colours":["#000000"]},
{"material":"PAHT","type":"Support","name":"Support for PA/PET","code":"65500","colours":["#C0DF16"]},
{"material":"PC","type":"Basic","name":"Black","code":"60101","colours":["#000000"]},
{"material":"PC","type":"Basic","name":"Clear Black","code":"60102","colours":["#5A5161"]},
{"material":"PC","type":"Basic","name":"Transparent","code":"60103","colours":["#BFC3C7"]},
{"material":"PC","type":"Basic","name":"White","code":"60100","colours":["#FFFFFF"]},
{"material":"PC","type":"FR","name":"Black","code":"63100","colours":["#000000"]},
{"material":"PC","type":"FR","name":"Gray","code":"63102","colours":["#A8A8AA"]},
{"material":"PC","type":"FR","name":"White","code":"63101","colours":["#FFFFFF"]},
{"material":"PET","type":"CF","name":"Black","code":"71100","colours":["#000000"]},
{"material":"PETG","type":"Basic","name":"Black","code":"30101","colours":["#000000"]},
{"material":"PETG","type":"Basic","name":"Black","code":"30105","colours":["#000000"]},
{"material":"PETG","type":"Basic","name":"Blue","code":"30600","colours":["#001489"]},
{"material":"PETG","type":"Basic","name":"Blue Gray","code":"30601","colours":["#2E4F66"]},
{"material":"PETG","type":"Basic","name":"Dark Beige","code":"30403","colours":["#DBC8B6"]},
{"material":"PETG","type":"Basic","name":"Dark Brown","code":"30800","colours":["#4F2C1D"]},
{"material":"PETG","type":"Basic","name":"Gold","code":"30401","colours":["#F6D86A"]},
{"material":"PETG","type":"Basic","name":"Gray","code":"30102","colours":["#ADB1B2"]},
{"material":"PETG","type":"Basic","name":"Gray","code":"30107","colours":["#7F7E83"]},
{"material":"PETG","type":"Basic","name":"Green","code":"30500","colours":["#009639"]},
{"material":"PETG","type":"Basic","name":"Green","code":"30502","colours":["#009639"]},
{"material":"PETG","type":"Basic","name":"Lake Blue","code":"30602","colours":["#0069B1"]},
{"material":"PETG","type":"Basic","name":"Lime Green","code":"30501","colours":["#7CD82B"]},
{"material":"PETG","type":"Basic","name":"Misty Blue","code":"30108","colours":["#688197"]},
{"material":"PETG","type":"Basic","name":"Nature","code":"30104","colours":["#D7D7D7"]},
{"material":"PETG","type":"Basic","name":"Navy Blue","code":"30604","colours":["#0086D6"]},
{"material":"PETG","type":"Basic","name":"Orange","code":"30300","colours":["#FF671F"]},
{"material":"PETG","type":"Basic","name":"Orange","code":"30301","colours":["#FF671F"]},
{"material":"PETG","type":"Basic","name":"Pine Green","code":"30503","colours":["#034638"]},
{"material":"PETG","type":"Basic","name":"Purple","code":"30700","colours":["#9E007E"]},
{"material":"PETG","type":"Basic","name":"Red","code":"30200","colours":["#D6001C"]},
{"material":"PETG","type":"Basic","name":"Red","code":"30201","colours":["#D6001C"]},
{"material":"PETG","type":"Basic","name":"Reflex Blue","code":"30603","colours":["#001489"]},
{"material":"PETG","type":"Basic","name":"Translucent","code":"30103","colours":["#FFFFFF"]},
{"material":"PETG","type":"Basic","name":"White","code":"30100","colours":["#EDF0F2"]},
{"material":"PETG","type":"Basic","name":"White","code":"30106","colours":["#FFFFFF"]},
{"material":"PETG","type":"Basic","name":"Yellow","code":"30400","colours":["#FCE300"]},
{"material":"PETG","type":"Basic","name":"Yellow","code":"30402","colours":["#FCE300"]},
{"material":"PETG","type":"CF","name":"Black","code":"31100","colours":["#000000"]},
{"material":"PETG","type":"CF","name":"Brick Red","code":"31200","colours":["#9F332A"]},
{"material":"PETG","type":"CF","name":"Indigo Blue","code":"31600","colours":["#324585"]},
{"material":"PETG","type":"CF","name":"Malachite Green","code":"31500","colours":["#16B08E"]},
{"material":"PETG","type":"CF","name":"Titan Gray","code":"31101","colours":["#565656"]},
{"material":"PETG","type":"CF","name":"Violet Purple","code":"31700","colours":["#583061"]},
{"material":"PETG","type":"HF","name":"Black","code":"33102","colours":["#000000"]},
{"material":"PETG","type":"HF","name":"Blue","code":"33600","colours":["#002E96"]},
{"material":"PETG","type":"HF","name":"Cream","code":"33401","colours":["#F9DFB9"]},
{"material":"PETG","type":"HF","name":"Dark Gray","code":"33103","colours":["#515151"]},
{"material":"PETG","type":"HF","name":"Forest Green","code":"33502","colours":["#39541A"]},
{"material":"PETG","type":"HF","name":"Gray","code":"33101","colours":["#ADB1B2"]},
{"material":"PETG","type":"HF","name":"Green","code":"33500","colours":["#00AE42"]},
{"material":"PETG","type":"HF","name":"Lake Blue","code":"33601","colours":["#1F79E5"]},
{"material":"PETG","type":"HF","name":"Lime Green","code":"33501","colours":["#6EE53C"]},
{"material":"PETG","type":"HF","name":"Orange","code":"33300","colours":["#F75403"]},
{"material":"PETG","type":"HF","name":"Peanut Brown","code":"33801","colours":["#875718"]},
{"material":"PETG","type":"HF","name":"Red","code":"33200","colours":["#EB3A3A"]},
{"material":"PETG","type":"HF","name":"White","code":"33100","colours":["#FFFFFF"]},
{"material":"PETG","type":"HF","name":"Yellow","code":"33400","colours":["#FFD00B"]},
{"material":"PETG","type":"Translucent","name":"Brown","code":"32800","colours":["#C9A381"]},
{"material":"PETG","type":"Translucent","name":"Clear","code":"32101","colours":["#EBEBEB"]},
{"material":"PETG","type":"Translucent","name":"Gray","code":"32100","colours":["#8E8E8E"]},
{"material":"PETG","type":"Translucent","name":"Light Blue","code":"32600","colours":["#61B0FF"]},
{"material":"PETG","type":"Translucent","name":"Olive","code":"32500","colours":["#748C45"]},
{"material":"PETG","type":"Translucent","name":"Orange","code":"32300","colours":["#FF911A"]},
{"material":"PETG","type":"Translucent","name":"Pink","code":"32200","colours":["#F9C1BD"]},
{"material":"PETG","type":"Translucent","name":"Purple","code":"32700","colours":["#D6ABFF"]},
{"material":"PETG","type":"Translucent","name":"Teal","code":"32501","colours":["#77EDD7"]},
{"material":"PLA","type":"Aero","name":"Black","code":"14103","colours":["#000000"]},
{"material":"PLA","type":"Aero","name":"Gray","code":"14104","colours":["#CDCECA"]},
{"material":"PLA","type":"Aero","name":"White","code":"14102","colours":["#FFFFFF"]},
{"material":"PLA","type":"Basic","name":"Bambu Green","code":"10501","colours":["#00AE42"]},
{"material":"PLA","type":"Basic","name":"Beige","code":"10201","colours":["#F7E6DE"]},
{"material":"PLA","type":"Basic","name":"Black","code":"10101","colours":["#000000"]},
{"material":"PLA","type":"Basic","name":"Blue","code":"10600","colours":["#0A2989"]},
{"material":"PLA","type":"Basic","name":"Blue","code":"10601","colours":["#0A2989"]},
{"material":"PLA","type":"Basic","name":"Blue Gray","code":"10602","colours":["#5B6579"]},
{"material":"PLA","type":"Basic","name":"Bright Green","code":"10503","colours":["#BECF00"]},
{"material":"PLA","type":"Basic","name":"Bronze","code":"10801","colours":["#847D48"]},
{"material":"PLA","type":"Basic","name":"Brown","code":"10800","colours":["#9D432C"]},
{"material":"PLA","type":"Basic","name":"Cobalt Blue","code":"10604","colours":["#0056B8"]},
{"material":"PLA","type":"Basic","name":"Cocoa Brown","code":"10802","colours":["#6F5034"]},
{"material":"PLA","type":"Basic","name":"Cyan","code":"10603","colours":["#0086D6"]},
{"material":"PLA","type":"Basic","name":"Dark Gray","code":"10105","colours":["#545454"]},
{"material":"PLA","type":"Basic","name":"Gold","code":"10401","colours":["#E4BD68"]},
{"material":"PLA","type":"Basic","name":"Gray","code":"10103","colours":["#8E9089"]},
{"material":"PLA","type":"Basic","name":"Green","code":"10500","colours":["#002914"]},
{"material":"PLA","type":"Basic","name":"Hot Pink","code":"10204","colours":["#F5547C"]},
{"material":"PLA","type":"Basic","name":"Indigo Purple","code":"10701","colours":["#482960"]},
{"material":"PLA","type":"Basic","name":"Jade White","code":"10100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Basic","name":"Light Gray","code":"10104","colours":["#D1D3D5"]},
{"material":"PLA","type":"Basic","name":"Magenta","code":"10202","colours":["#EC008C"]},
{"material":"PLA","type":"Basic","name":"Maroon Red","code":"10205","colours":["#9D2235"]},
{"material":"PLA","type":"Basic","name":"Mistletoe Green","code":"10502","colours":["#3F8E43"]},
{"material":"PLA","type":"Basic","name":"Orange","code":"10300","colours":["#FF6A13"]},
{"material":"PLA","type":"Basic","name":"Pink","code":"10203","colours":["#F55A74"]},
{"material":"PLA","type":"Basic","name":"Pumpkin Orange","code":"10301","colours":["#FF9016"]},
{"material":"PLA","type":"Basic","name":"Purple","code":"10700","colours":["#5E43B7"]},
{"material":"PLA","type":"Basic","name":"Red","code":"10200","colours":["#C12E1F"]},
{"material":"PLA","type":"Basic","name":"Silver","code":"10102","colours":["#A6A9AA"]},
{"material":"PLA","type":"Basic","name":"Sunflower Yellow","code":"10402","colours":["#FEC600"]},
{"material":"PLA","type":"Basic","name":"Turquoise","code":"10605","colours":["#00B1B7"]},
{"material":"PLA","type":"Basic","name":"Yellow","code":"10400","colours":["#F4EE2A"]},
{"material":"PLA","type":"CF","name":"Black","code":"14100","colours":["#000000"]},
{"material":"PLA","type":"CF","name":"Burgundy Red","code":"14200","colours":["#951E23"]},
{"material":"PLA","type":"CF","name":"Iris Purple","code":"14700","colours":["#69398E"]},
{"material":"PLA","type":"CF","name":"Jeans Blue","code":"14600","colours":["#6E88BC"]},
{"material":"PLA","type":"CF","name":"Lava Gray","code":"14101","colours":["#4D5054"]},
{"material":"PLA","type":"CF","name":"Matcha Green","code":"14500","colours":["#5C9748"]},
{"material":"PLA","type":"CF","name":"Royal Blue","code":"14601","colours":["#2842AD"]},
{"material":"PLA","type":"CMYK","name":"Lithophane Bundle","code":"","colours":["#00FFFF","#FFFF00","#FF00FF","#FFFFFF"]},
{"material":"PLA","type":"Galaxy","name":"Brown","code":"13203","colours":["#684A43"]},
{"material":"PLA","type":"Galaxy","name":"Green","code":"13503","colours":["#3B665E"]},
{"material":"PLA","type":"Galaxy","name":"Nebula","code":"13504","colours":["#424379"]},
{"material":"PLA","type":"Galaxy","name":"Purple","code":"13602","colours":["#594177"]},
{"material":"PLA","type":"Glow","name":"Glow Blue","code":"15600","colours":["#7AC0E9"]},
{"material":"PLA","type":"Glow","name":"Glow Green","code":"15500","colours":["#A1FFAC"]},
{"material":"PLA","type":"Glow","name":"Glow Orange","code":"15300","colours":["#FF9D5B"]},
{"material":"PLA","type":"Glow","name":"Glow Pink","code":"15200","colours":["#F17B8F"]},
{"material":"PLA","type":"Glow","name":"Glow Yellow","code":"15400","colours":["#F8FF80"]},
{"material":"PLA","type":"Gradient","name":"Arctic Whisper","code":"10900","colours":["#9CDBD9","#FFFFFF"]},
{"material":"PLA","type":"Gradient","name":"Blueberry Bubblegum","code":"10905","colours":["#6FCAEF","#8573DD"]},
{"material":"PLA","type":"Gradient","name":"Cotton Candy Cloud","code":"10907","colours":["#E7C1D5","#8EC9E9"]},
{"material":"PLA","type":"Gradient","name":"Dusk Glare","code":"10906","colours":["#ED6022","#FFCBA6"]},
{"material":"PLA","type":"Gradient","name":"Mint Lime","code":"10904","colours":["#4EC83A","#D7FE9E"]},
{"material":"PLA","type":"Gradient","name":"Ocean to Meadow","code":"10902","colours":["#307FE2","#54FF9B"]},
{"material":"PLA","type":"Gradient","name":"Pink Citrus","code":"10903","colours":["#E4555E","#FCDAD2"]},
{"material":"PLA","type":"Gradient","name":"Solar Breeze","code":"10901","colours":["#FC776A","#FFF7F5"]},
{"material":"PLA","type":"Lite","name":"Beige","code":"16700","colours":["#F7E6DE"]},
{"material":"PLA","type":"Lite","name":"Black","code":"16100","colours":["#000000"]},
{"material":"PLA","type":"Lite","name":"Blue","code":"16601","colours":["#004EA8"]},
{"material":"PLA","type":"Lite","name":"Cocoa Brown","code":"","colours":["#8E3C06"]},
{"material":"PLA","type":"Lite","name":"Cyan","code":"16600","colours":["#00FFFF"]},
{"material":"PLA","type":"Lite","name":"Dark Gray","code":"16102","colours":["#6F6E6D"]},
{"material":"PLA","type":"Lite","name":"Gray","code":"16101","colours":["#9FA19F"]},
{"material":"PLA","type":"Lite","name":"Green","code":"16501","colours":["#00BB31"]},
{"material":"PLA","type":"Lite","name":"Orange","code":"16301","colours":["#FF671F"]},
{"material":"PLA","type":"Lite","name":"Red","code":"16200","colours":["#FF0000"]},
{"material":"PLA","type":"Lite","name":"Sunflower Yellow","code":"16401","colours":["#FFB549"]},
{"material":"PLA","type":"Lite","name":"White","code":"16103","colours":["#FFFEF7"]},
{"material":"PLA","type":"Lite","name":"Yellow","code":"16400","colours":["#FFD834"]},
{"material":"PLA","type":"Marble","name":"Red Granite","code":"13201","colours":["#AD4E38"]},
{"material":"PLA","type":"Marble","name":"White Marble","code":"13103","colours":["#F7F3F0"]},
{"material":"PLA","type":"Matte","name":"Apple Green","code":"11502","colours":["#C2E189"]},
{"material":"PLA","type":"Matte","name":"Ash Gray","code":"11102","colours":["#9B9EA0"]},
{"material":"PLA","type":"Matte","name":"Bone White","code":"11103","colours":["#CBC6B8"]},
{"material":"PLA","type":"Matte","name":"Caramel","code":"11803","colours":["#AE835B"]},
{"material":"PLA","type":"Matte","name":"Charcoal","code":"11101","colours":["#000000"]},
{"material":"PLA","type":"Matte","name":"Dark Blue","code":"11602","colours":["#042F56"]},
{"material":"PLA","type":"Matte","name":"Dark Brown","code":"11801","colours":["#7D6556"]},
{"material":"PLA","type":"Matte","name":"Dark Chocolate","code":"11802","colours":["#4D3324"]},
{"material":"PLA","type":"Matte","name":"Dark Green","code":"11501","colours":["#68724D"]},
{"material":"PLA","type":"Matte","name":"Dark Red","code":"11202","colours":["#BB3D43"]},
{"material":"PLA","type":"Matte","name":"Desert Tan","code":"11401","colours":["#E8DBB7"]},
{"material":"PLA","type":"Matte","name":"Grass Green","code":"11500","colours":["#61C680"]},
{"material":"PLA","type":"Matte","name":"Ice Blue","code":"11601","colours":["#A3D8E1"]},
{"material":"PLA","type":"Matte","name":"Ivory White","code":"11100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Matte","name":"Latte Brown","code":"11800","colours":["#D3B7A7"]},
{"material":"PLA","type":"Matte","name":"Lemon Yellow","code":"11400","colours":["#F7D959"]},
{"material":"PLA","type":"Matte","name":"Lilac Purple","code":"11700","colours":["#AE96D4"]},
{"material":"PLA","type":"Matte","name":"Mandarin Orange","code":"11300","colours":["#F99963"]},
{"material":"PLA","type":"Matte","name":"Marine Blue","code":"11600","colours":["#0078BF"]},
{"material":"PLA","type":"Matte","name":"Nardo Gray","code":"11104","colours":["#757575"]},
{"material":"PLA","type":"Matte","name":"Plum","code":"11204","colours":["#950051"]},
{"material":"PLA","type":"Matte","name":"Sakura Pink","code":"11201","colours":["#E8AFCF"]},
{"material":"PLA","type":"Matte","name":"Scarlet Red","code":"11200","colours":["#DE4343"]},
{"material":"PLA","type":"Matte","name":"Sky Blue","code":"11603","colours":["#56B7E6"]},
{"material":"PLA","type":"Matte","name":"Terracotta","code":"11203","colours":["#B15533"]},
{"material":"PLA","type":"Metal","name":"Cobalt Blue Metallic","code":"13600","colours":["#39699E"]},
{"material":"PLA","type":"Metal","name":"Copper Brown Metallic","code":"13800","colours":["#AA6443"]},
{"material":"PLA","type":"Metal","name":"Iridium Gold Metallic","code":"13400","colours":["#B39B84"]},
{"material":"PLA","type":"Metal","name":"Iron Gray Metallic","code":"13100","colours":["#43403D"]},
{"material":"PLA","type":"Metal","name":"Oxide Green Metallic","code":"13500","colours":["#1D7C6A"]},
{"material":"PLA","type":"Pure","name":"Absolute Black","code":"17101","colours":["#000000"]},
{"material":"PLA","type":"Pure","name":"Apricot","code":"17300","colours":["#FFB673"]},
{"material":"PLA","type":"Pure","name":"Baby Blue","code":"17600","colours":["#A4DBE8"]},
{"material":"PLA","type":"Pure","name":"Milky Pink","code":"17200","colours":["#F7CED7"]},
{"material":"PLA","type":"Pure","name":"Pure White","code":"17100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Silk","name":"Blue","code":"13601","colours":["#147BD1"]},
{"material":"PLA","type":"Silk","name":"Copper","code":"13300","colours":["#5E4B3C"]},
{"material":"PLA","type":"Silk","name":"Gold","code":"13401","colours":["#E5B03D"]},
{"material":"PLA","type":"Silk","name":"Green","code":"13502","colours":["#4CE4A0"]},
{"material":"PLA","type":"Silk","name":"Pink","code":"13202","colours":["#EEB1C1"]},
{"material":"PLA","type":"Silk","name":"Purple","code":"13701","colours":["#580490"]},
{"material":"PLA","type":"Silk","name":"Silver","code":"13104","colours":["#ACB4B5"]},
{"material":"PLA","type":"Silk","name":"White","code":"13105","colours":["#FFFFFF"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Aurora Purple","code":"13909","colours":["#7F3696","#006EC9"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Blue Hawaii (Blue-Green)","code":"13904","colours":["#60A4E8","#4CE4A0"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Dawn Radiance","code":"13912","colours":["#EC984C","#6CD4BC","#A66EB9","#D87694"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Gilded Rose (Pink-Gold)","code":"13901","colours":["#FF9425","#FCA2BF"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Midnight Blaze (Blue-Red)","code":"13902","colours":["#0047BB","#7D1B49"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Mystic Magenta (Purple+Green)","code":"13913","colours":["#720062","#3A913F"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Neon City (Blue-Magenta)","code":"13903","colours":["#0047BB","#BB22A3"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Phantom Blue (Blue-Black)","code":"13916","colours":["#000000","#00629B"]},
{"material":"PLA","type":"Silk Multi-Color","name":"South Beach","code":"13906","colours":["#F772A4","#00918B"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Velvet Eclipse (Black-Red)","code":"13905","colours":["#000000","#C80000"]},
{"material":"PLA","type":"Silk+","name":"Baby Blue","code":"13603","colours":["#A8C6EE"]},
{"material":"PLA","type":"Silk+","name":"Blue","code":"13604","colours":["#008BDA"]},
{"material":"PLA","type":"Silk+","name":"Candy Green","code":"13506","colours":["#018814"]},
{"material":"PLA","type":"Silk+","name":"Candy Red","code":"13205","colours":["#D02727"]},
{"material":"PLA","type":"Silk+","name":"Champagne","code":"13404","colours":["#F3CFB2"]},
{"material":"PLA","type":"Silk+","name":"Gold","code":"13405","colours":["#F4A925"]},
{"material":"PLA","type":"Silk+","name":"Mint","code":"13507","colours":["#96DCB9"]},
{"material":"PLA","type":"Silk+","name":"Pink","code":"13207","colours":["#F7ADA6"]},
{"material":"PLA","type":"Silk+","name":"Purple","code":"13702","colours":["#8671CB"]},
{"material":"PLA","type":"Silk+","name":"Rose Gold","code":"13206","colours":["#BA9594"]},
{"material":"PLA","type":"Silk+","name":"Silver","code":"13109","colours":["#C8C8C8"]},
{"material":"PLA","type":"Silk+","name":"Titan Gray","code":"13108","colours":["#5F6367"]},
{"material":"PLA","type":"Silk+","name":"White","code":"13110","colours":["#FFFFFF"]},
{"material":"PLA","type":"Sparkle","name":"Alpine Green Sparkle","code":"13501","colours":["#3F5443"]},
{"material":"PLA","type":"Sparkle","name":"Classic Gold Sparkle","code":"13402","colours":["#CEA629"]},
{"material":"PLA","type":"Sparkle","name":"Crimson Red Sparkle","code":"13200","colours":["#792B36"]},
{"material":"PLA","type":"Sparkle","name":"Onyx Black Sparkle","code":"13101","colours":["#2D2B28"]},
{"material":"PLA","type":"Sparkle","name":"Royal Purple Sparkle","code":"13700","colours":["#483D8B"]},
{"material":"PLA","type":"Sparkle","name":"Slate Gray Sparkle","code":"13102","colours":["#8E9089"]},
{"material":"PLA","type":"Support","name":"Support for PLA Black","code":"65101","colours":["#000000"]},
{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65104","colours":["#FFFFFF"]},
{"material":"PLA","type":"Support","name":"Support for PLA/PETG Black","code":"65103","colours":["#000000"]},
{"material":"PLA","type":"Support","name":"Support for PLA/PETG Nature","code":"65102","colours":["#FBF5E4"]},
{"material":"PLA","type":"Tough","name":"Black","code":"","colours":["#000000"]},
{"material":"PLA","type":"Tough","name":"Cream White","code":"","colours":["#FAFAFA"]},
{"material":"PLA","type":"Tough","name":"Gray","code":"12102","colours":["#515A6C"]},
{"material":"PLA","type":"Tough","name":"Lavender-Blue","code":"12700","colours":["#6667AB"]},
{"material":"PLA","type":"Tough","name":"Light-Blue","code":"12600","colours":["#0085AD"]},
{"material":"PLA","type":"Tough","name":"Orange","code":"12300","colours":["#FF7F41"]},
{"material":"PLA","type":"Tough","name":"Pine Green","code":"12500","colours":["#00482B"]},
{"material":"PLA","type":"Tough","name":"Silver","code":"12103","colours":["#9D9D9D"]},
{"material":"PLA","type":"Tough","name":"Vermilion-Red","code":"12200","colours":["#DD3C22"]},
{"material":"PLA","type":"Tough","name":"White","code":"12100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Tough","name":"Yellow","code":"12400","colours":["#FEDB00"]},
{"material":"PLA","type":"Tough+","name":"Black","code":"12104","colours":["#000000"]},
{"material":"PLA","type":"Tough+","name":"Cyan","code":"12601","colours":["#009BD9"]},
{"material":"PLA","type":"Tough+","name":"Gray","code":"12105","colours":["#AFB1AE"]},
{"material":"PLA","type":"Tough+","name":"Orange","code":"12301","colours":["#DC3A27"]},
{"material":"PLA","type":"Tough+","name":"Silver","code":"12106","colours":["#959698"]},
{"material":"PLA","type":"Tough+","name":"White","code":"12107","colours":["#FFFFFF"]},
{"material":"PLA","type":"Tough+","name":"Yellow","code":"12401","colours":["#F4D53F"]},
{"material":"PLA","type":"Translucent","name":"Blue","code":"13611","colours":["#0047BB"]},
{"material":"PLA","type":"Translucent","name":"Cherry Pink","code":"13211","colours":["#F5B6CD"]},
{"material":"PLA","type":"Translucent","name":"Ice Blue","code":"13610","colours":["#B8CDE9"]},
{"material":"PLA","type":"Translucent","name":"Lavender","code":"13711","colours":["#B8ACD6"]},
{"material":"PLA","type":"Translucent","name":"Light Jade","code":"13510","colours":["#96D8AF"]},
{"material":"PLA","type":"Translucent","name":"Mellow Yellow","code":"13410","colours":["#F5DBAB"]},
{"material":"PLA","type":"Translucent","name":"Orange","code":"13301","colours":["#F74E02"]},
{"material":"PLA","type":"Translucent","name":"Purple","code":"13710","colours":["#8344B0"]},
{"material":"PLA","type":"Translucent","name":"Red","code":"13210","colours":["#B50011"]},
{"material":"PLA","type":"Translucent","name":"Teal","code":"13612","colours":["#009FA1"]},
{"material":"PLA","type":"Wood","name":"Black Walnut","code":"13107","colours":["#4F3F24"]},
{"material":"PLA","type":"Wood","name":"Classic Birch","code":"13505","colours":["#918669"]},
{"material":"PLA","type":"Wood","name":"Clay Brown","code":"13801","colours":["#995F11"]},
{"material":"PLA","type":"Wood","name":"Ochre Yellow","code":"13403","colours":["#C98935"]},
{"material":"PLA","type":"Wood","name":"Rosewood","code":"13204","colours":["#4C241C"]},
{"material":"PLA","type":"Wood","name":"White Oak","code":"13106","colours":["#D6CCA3"]},
{"material":"PPA","type":"CF","name":"Black","code":"73100","colours":["#000000"]},
{"material":"PPS","type":"CF","name":"Black","code":"76100","colours":["#000000"]},
{"material":"PVA","type":"Support","name":"Clear","code":"66400","colours":["#F0F1A8"]},
{"material":"TPU","type":"68D (For AMS)","name":"Black","code":"53101","colours":["#000000"]},
{"material":"TPU","type":"68D (For AMS)","name":"Blue","code":"53600","colours":["#5898DD"]},
{"material":"TPU","type":"68D (For AMS)","name":"Gray","code":"53102","colours":["#939393"]},
{"material":"TPU","type":"68D (For AMS)","name":"Neon Green","code":"53500","colours":["#90FF1A"]},
{"material":"TPU","type":"68D (For AMS)","name":"Red","code":"53200","colours":["#ED0000"]},
{"material":"TPU","type":"68D (For AMS)","name":"White","code":"53100","colours":["#FFFFFF"]},
{"material":"TPU","type":"68D (For AMS)","name":"Yellow","code":"53400","colours":["#F9EF41"]},
{"material":"TPU","type":"85A","name":"Black","code":"51107","colours":["#000000"]},
{"material":"TPU","type":"85A","name":"Flesh","code":"51201","colours":["#FFF2D5"]},
{"material":"TPU","type":"85A","name":"Light Cyan","code":"51500","colours":["#C3E2D6"]},
{"material":"TPU","type":"85A","name":"Lime Green","code":"51501","colours":["#EAF28F"]},
{"material":"TPU","type":"85A","name":"Neon Orange","code":"51305","colours":["#F68B1B"]},
{"material":"TPU","type":"90A","name":"Black","code":"51103","colours":["#000000"]},
{"material":"TPU","type":"90A","name":"Blaze","code":"51901","colours":["#F1AAA8","#D21B3C"]},
{"material":"TPU","type":"90A","name":"Cocoa Brown","code":"51800","colours":["#5C4738"]},
{"material":"TPU","type":"90A","name":"Crystal Blue","code":"51601","colours":["#7EB4E1"]},
{"material":"TPU","type":"90A","name":"Frozen","code":"51900","colours":["#FFFFFF","#40B6E4"]},
{"material":"TPU","type":"90A","name":"Grape Jelly","code":"51700","colours":["#D6ABFF"]},
{"material":"TPU","type":"90A","name":"Quicksilver","code":"51106","colours":["#9EA2A2"]},
{"material":"TPU","type":"90A","name":"White","code":"51105","colours":["#FFFFFF"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Black","code":"51100","colours":["#000000"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Blue","code":"51600","colours":["#0072CE"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Gray","code":"51101","colours":["#898D8D"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Red","code":"51200","colours":["#C8102E"]},
{"material":"TPU","type":"95A HF/High Speed","name":"White","code":"51102","colours":["#FFFFFF"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Yellow","code":"51400","colours":["#F3E600"]}
]
}

View file

@ -154,3 +154,183 @@ CREATE TABLE IF NOT EXISTS duplicate_dismissals (
signature TEXT PRIMARY KEY, signature TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Where a model's tag came from: user (typed in), rule (automatic tagging) or ai.
-- Rule and AI passes only add or remove their own tags; yours are never touched.
ALTER TABLE model_tags ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user';
-- Automatic tagging: fingerprint of the rules and names the rule tags were last
-- worked out from, so a model is re-checked only when one of those changes.
ALTER TABLE models ADD COLUMN IF NOT EXISTS autotag_key TEXT;
-- Semantic search: a local embedding of the model's text, and the fingerprint of
-- the text (and embedding model) it was made from.
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding REAL[];
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding_key TEXT;
-- AI tagging runs, kept so they can be watched, stopped and resumed.
ALTER TABLE models ADD COLUMN IF NOT EXISTS ai_tagged_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS ai_runs (
id SERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'running', -- running | paused | stopped | done | failed
provider TEXT,
model TEXT,
options JSONB NOT NULL DEFAULT '{}', -- { overwriteDescriptions, maxCost, pricesKnown }
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
cost_usd NUMERIC(12, 6) NOT NULL DEFAULT 0,
error TEXT,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS ai_run_items (
run_id INTEGER NOT NULL REFERENCES ai_runs(id) ON DELETE CASCADE,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | error
error TEXT,
tags TEXT[],
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_ai_run_items_status ON ai_run_items(run_id, status);
-- Your own automatic tagging rules: a word or phrase in a name -> a tag.
CREATE TABLE IF NOT EXISTS tag_rules (
id SERIAL PRIMARY KEY,
phrase TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (phrase, tag)
);
-- AI renaming: the name a model had before AI first renamed it (for undo), and
-- what each run renamed.
ALTER TABLE models ADD COLUMN IF NOT EXISTS original_name TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_from TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_to TEXT;
-- Collections the AI suggested for a model, waiting for you to accept or dismiss.
-- Dismissed ones are kept so the same idea isn't suggested for that model again.
CREATE TABLE IF NOT EXISTS collection_suggestions (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | dismissed
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_suggestions_model_name ON collection_suggestions(model_id, lower(name));
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS collections TEXT[];
-- MakerWorld collections watched for new models (lib/collectionWatch.js).
CREATE TABLE IF NOT EXISTS watched_collections (
id SERIAL PRIMARY KEY,
site TEXT NOT NULL DEFAULT 'makerworld',
source_id TEXT NOT NULL, -- the collection's id on the site
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', -- its name there, refreshed on each check
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, -- where new models land
ai_tag BOOLEAN NOT NULL DEFAULT false,
fetch_files BOOLEAN NOT NULL DEFAULT false, -- download files during a check, not just metadata
interval_hours INTEGER NOT NULL DEFAULT 6,
enabled BOOLEAN NOT NULL DEFAULT true,
last_checked_at TIMESTAMPTZ,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_status TEXT, -- ok | error
last_error TEXT,
last_error_reason TEXT, -- makerworld_token: offer the token prompt
last_added INTEGER NOT NULL DEFAULT 0,
listed INTEGER, -- models the page listed at the last check
total INTEGER, -- models the site says the collection holds
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site, source_id)
);
-- What a watch has already seen, so a model isn't imported (or retried) twice.
CREATE TABLE IF NOT EXISTS watched_collection_items (
watch_id INTEGER NOT NULL REFERENCES watched_collections(id) ON DELETE CASCADE,
design_id TEXT NOT NULL,
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, -- imported | partial (no files yet) | existing | error
error TEXT,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (watch_id, design_id)
);
ALTER TABLE watched_collections ADD COLUMN IF NOT EXISTS fetch_files BOOLEAN NOT NULL DEFAULT false;
-- ------------------------------------------------------------------ filament
-- The spools you own. Weights in grams; remaining is what's left on the spool,
-- null when you haven't said. ams_slot is filled in later by AMS sync, and
-- stays null for spools logged by hand.
CREATE TABLE IF NOT EXISTS filament_spools (
id SERIAL PRIMARY KEY,
material TEXT NOT NULL, -- PLA, PETG, ABS, TPU…
colour TEXT NOT NULL DEFAULT '', -- what you call it, e.g. "Bambu Matte Ivory"
colour_hex TEXT, -- #RRGGBB when known, to match a project's colours
brand TEXT NOT NULL DEFAULT '',
total_g INTEGER, -- spool weight when new
remaining_g INTEGER, -- what's left; null = unknown
ams_slot TEXT, -- "<printer>/<unit>/<slot>" once AMS sync lands
code TEXT, -- maker's filament code (Bambu 10101 etc.), what the AMS reports
notes TEXT NOT NULL DEFAULT '',
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
-- What a model asks for, read once from its 3MF project rather than on every
-- page: [{ type, colour }]. A background pass fills these in, the way
-- thumbnails and fingerprints are done, because the reader shells out to Python.
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments JSONB;
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS code TEXT;
-- A Bambu spool's RFID identity and where it was last seen, so a printer's
-- report finds the same spool again instead of adding it twice.
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS rfid_uuid TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS tag_uid TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS product_code TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS printer_serial TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS filament_spools_rfid ON filament_spools (rfid_uuid) WHERE rfid_uuid IS NOT NULL;
-- ------------------------------------------------------------------ printers
-- The printers you've added. access_code is read by the server only.
CREATE TABLE IF NOT EXISTS printers (
id SERIAL PRIMARY KEY,
nickname TEXT, -- shown instead of the model when set
model TEXT, -- A1, P1S, … or Other
host TEXT,
serial TEXT,
access_code TEXT,
ams_type TEXT, -- none | ams_lite | ams | ams_2_pro | ams_ht
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
-- What each printer last reported loaded, slot by slot, for its card.
ALTER TABLE printers ADD COLUMN IF NOT EXISTS loaded JSONB;
ALTER TABLE printers ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
-- The one printer that used to live in Settings becomes the first entry here.
INSERT INTO printers (host, serial, access_code)
SELECT h.value, s.value, c.value
FROM settings h
JOIN settings s ON s.key = 'printer_serial'
JOIN settings c ON c.key = 'printer_access_code'
WHERE h.key = 'printer_host'
AND coalesce(h.value, '') <> '' AND coalesce(s.value, '') <> '' AND coalesce(c.value, '') <> ''
AND NOT EXISTS (SELECT 1 FROM printers);
-- …and its copy in Settings goes, so the access code isn't kept twice — but
-- only once that printer is safely here.
DELETE FROM settings
WHERE key IN ('printer_host', 'printer_serial', 'printer_access_code')
AND EXISTS (
SELECT 1 FROM printers p
JOIN settings s ON s.key = 'printer_serial' AND s.value = p.serial
);

View file

@ -6,7 +6,11 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-printhive} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-printhive}
POSTGRES_DB: printhive POSTGRES_DB: printhive
volumes: volumes:
- pgdata:/var/lib/postgresql/data # Set separately from DATA_DIR: Postgres needs a filesystem with real
# ownership and permissions (ext4, xfs, APFS...), which exFAT, FAT and
# most network shares don't provide. Keep it on local disk if the files
# live somewhere like that.
- ${PGDATA_DIR:-/Volumes/Data/printhive/pgdata}:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U printhive -d printhive"] test: ["CMD-SHELL", "pg_isready -U printhive -d printhive"]
interval: 5s interval: 5s
@ -33,10 +37,14 @@ services:
MAKERWORLD_TOKEN: ${MAKERWORLD_TOKEN:-} MAKERWORLD_TOKEN: ${MAKERWORLD_TOKEN:-}
PRINTABLES_TOKEN: ${PRINTABLES_TOKEN:-} PRINTABLES_TOKEN: ${PRINTABLES_TOKEN:-}
volumes: volumes:
- uploads:/app/uploads # Absolute on purpose: a relative path resolves against whatever folder
- thumbnails:/app/thumbnails # runs compose, which isn't this one when a stack manager deploys it —
- glb:/app/glb # and an empty folder makes a working install look wiped.
- extracted:/app/extracted # cache of files unpacked from archives # uploads and pgdata hold everything that matters; the other two rebuild.
- ${DATA_DIR:-/Volumes/Data/printhive}/uploads:/app/uploads
- ${DATA_DIR:-/Volumes/Data/printhive}/thumbnails:/app/thumbnails
- ${DATA_DIR:-/Volumes/Data/printhive}/glb:/app/glb
- ${DATA_DIR:-/Volumes/Data/printhive}/extracted:/app/extracted # cache of files unpacked from archives
# No host port published — Traefik auto-discovers the app on the shared network. # No host port published — Traefik auto-discovers the app on the shared network.
networks: networks:
- default # talk to the db - default # talk to the db
@ -52,13 +60,6 @@ services:
- "traefik.http.routers.printhive.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" - "traefik.http.routers.printhive.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
- "traefik.http.services.printhive.loadbalancer.server.port=3000" - "traefik.http.services.printhive.loadbalancer.server.port=3000"
volumes:
pgdata:
uploads:
thumbnails:
glb:
extracted:
networks: networks:
default: default:
# The existing Traefik network (the `proxy` network Traefik runs on). # The existing Traefik network (the `proxy` network Traefik runs on).

45
eslint.config.js Normal file
View file

@ -0,0 +1,45 @@
// Lint rules, kept to the mistakes tests can't catch — chiefly a name that
// doesn't exist, which only shows up when that line finally runs.
const js = require('@eslint/js');
const globals = require('globals');
module.exports = [
{ ignores: ['node_modules/**', 'uploads/**', 'thumbnails/**', 'glb/**', 'extracted/**', 'models/**', 'data/**', 'backups/**', 'tools/venv/**'] },
// The server: Node, CommonJS.
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2023,
sourceType: 'commonjs',
globals: { ...globals.node },
},
rules: {
...js.configs.recommended.rules,
'no-unused-vars': ['error', { argsIgnorePattern: '^_|^next$', caughtErrors: 'none' }],
},
},
// The browser: plain scripts sharing globals across files, plus one module.
{
files: ['public/**/*.js'],
languageOptions: {
sourceType: 'script',
globals: {
...globals.browser,
ModelViewer: 'readonly', // viewer.js hangs it on window
PRINTER_MODELS: 'readonly', // printer-art.js does the same
},
},
},
{
files: ['public/viewer.js'],
languageOptions: { sourceType: 'module' },
},
// Tests.
{
files: ['test/**/*.js'],
languageOptions: { globals: { ...globals.node } },
},
];

378
lib/ai.js Normal file
View file

@ -0,0 +1,378 @@
// AI tags, descriptions, names and collection ideas through any OpenAI-compatible
// chat API: DeepSeek by default, or OpenRouter, OpenAI, or a local server such as
// Ollama or LM Studio. The API key stays on the server.
const PROVIDERS = {
deepseek: { label: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash', prices: { input: 0.3, output: 1.2 } },
openrouter: { label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', model: '' },
openai: { label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: '' },
custom: { label: 'Other (OpenAI-compatible)', baseUrl: '', model: '' },
};
// The system prompt is assembled from the parts a run asks for.
const BASE_PROMPT = `You catalogue 3D-printable models in a maker's library. Reply with JSON only, as one object containing exactly the fields described below.`;
const DESCRIBE_PROMPT = `
"tags": 3 to 8 short lowercase tags someone would search for: what the object is, what it's for, its theme or style, and print traits (print-in-place, articulated, multicolor, needs supports) only when evident. Prefer singular nouns. When a tag already used in the library fits, use it instead of a new synonym. Never use generic tags like "3d print", "model", "stl" or "3mf".
"description": one or two plain sentences on what the object is and what it's for. Don't invent sizes, print settings or claims.`;
const NAME_PROMPT = `
"name": the model's name as a clear title. If the current name already says clearly what the model is, return it exactly as it is. Otherwise write a short title-case name (under 60 characters) saying what it is, using the picture and details: drop file-name leftovers (underscores, stray spaces, version numbers like "v4", "final", file extensions), fix typos, and remove site boilerplate like "Free 3D Print Model - MakerWorld". Keep designer, character or product names that are part of what it is.`;
const COLLECTIONS_PROMPT = `
"collections": 0 to 3 collections this model belongs in: broad groups someone would browse a library by, such as "Bookends & Book Holders", "Desk Organizers", "Pokémon" or "Halloween", never a name for this one model. When one of the library's existing collections fits, use its name exactly as written. Return [] when nothing fits well.`;
const GENERIC_TAGS = new Set([
'3d print', '3d printing', '3d printed', '3d printable', '3d model', 'model', 'print', 'printable', 'file',
'stl', '3mf', 'obj', 'step', 'stp', 'gcode',
]);
class AiError extends Error {
constructor(message, status, kind = 'http') {
super(message);
this.status = status;
this.kind = kind; // http | network | timeout
}
}
// The provider answered, but not with the JSON asked for.
class ReplyError extends Error {}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Cuts text to at most max UTF-16 units without splitting an emoji in half. A lone
// half (surrogate) is still sent as JSON, but some APIs (DeepSeek) refuse to parse it.
function clip(text, max) {
const s = String(text);
if (s.length <= max) return s;
const cut = s.slice(0, max);
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
}
// Settings values (ai_provider, ai_base_url, …) -> a client config, filling the
// gaps from the provider's preset.
function configFrom(values = {}) {
const provider = PROVIDERS[values.ai_provider] ? values.ai_provider : 'deepseek';
const preset = PROVIDERS[provider];
const price = (value, fallback) => (value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value))
? Number(value) : fallback ?? null);
return {
provider,
label: preset.label,
baseUrl: String(values.ai_base_url || preset.baseUrl || '').trim().replace(/\/+$/, ''),
apiKey: values.ai_api_key || '',
model: String(values.ai_model || preset.model || '').trim(),
sendImages: values.ai_send_images !== 'false',
prices: {
input: price(values.ai_price_input, preset.prices && preset.prices.input),
output: price(values.ai_price_output, preset.prices && preset.prices.output),
},
};
}
// What still needs filling in before AI can run, or null.
function missingConfig(config) {
if (!config.baseUrl) return 'Add the API address in Settings → AI.';
if (!config.model) return 'Choose a model in Settings → AI.';
// Local servers (Ollama, LM Studio) usually don't need a key.
if (!config.apiKey && config.provider !== 'custom') return 'Add your API key in Settings → AI.';
return null;
}
// "120 × 80 × 40 mm" from the biggest file's bounding box, if known.
function modelSize(model) {
const dims = (model.files || [])
.map((f) => f.bbox && [f.bbox.x ?? f.bbox.width, f.bbox.y ?? f.bbox.depth, f.bbox.z ?? f.bbox.height].map(Number))
.filter((d) => d && d.every((n) => Number.isFinite(n) && n > 0))
.sort((a, b) => b[0] * b[1] * b[2] - a[0] * a[1] * a[2])[0];
return dims ? `${dims.map((n) => Math.round(n)).join(' × ')} mm` : null;
}
function modelSummary(model, knownTags = []) {
const lines = [`Name: ${model.name}`];
const files = (model.files || []).map((f) => f.originalName).filter(Boolean);
if (files.length) lines.push(`Files: ${files.slice(0, 30).join(', ')}`);
if (model.entryNames && model.entryNames.length) lines.push(`Inside archives: ${model.entryNames.slice(0, 40).join(', ')}`);
if (model.source_site) lines.push(`Source: ${model.source_site}${model.designer ? `, by ${model.designer.name}` : ''}`);
const size = modelSize(model);
if (size) lines.push(`Size: ${size}`);
if (model.tags && model.tags.length) lines.push(`Tags so far: ${model.tags.join(', ')}`);
if (model.description && model.description.trim()) lines.push(`Description so far: ${clip(model.description.trim(), 800)}`);
if (knownTags.length) lines.push(`Tags already used in this library: ${knownTags.join(', ')}`);
return lines.join('\n');
}
// Which fields to ask for: describe (tags + description, the default), a clearer
// name, and collection ideas.
function buildMessages(model, {
image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [],
} = {}) {
let text = modelSummary(model, describe ? knownTags : []);
if (suggestCollections) {
text += collectionNames.length
? `\nExisting collections in this library: ${collectionNames.join(', ')}`
: '\nThis library has no collections yet.';
}
// An example object helps JSON mode (DeepSeek can otherwise send back an empty reply).
const example = {};
if (describe) Object.assign(example, { tags: ['wall hook', 'headphone holder'], description: 'A wall hook for hanging headphones.' });
if (suggestName) example.name = 'Headphone Wall Hook';
if (suggestCollections) example.collections = ['Desk Organizers'];
const system = BASE_PROMPT
+ (describe ? DESCRIBE_PROMPT : '')
+ (suggestName ? NAME_PROMPT : '')
+ (suggestCollections ? COLLECTIONS_PROMPT : '')
+ `\nThe format, shown for a different model: ${JSON.stringify(example)}`;
// Stored names and descriptions may already hold a broken emoji; send a stand-in instead.
text = text.toWellFormed();
const content = image
? [{ type: 'text', text }, { type: 'image_url', image_url: { url: image } }]
: text;
return [{ role: 'system', content: system }, { role: 'user', content }];
}
function normalizeTags(value) {
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
const tags = [];
for (const raw of list) {
const tag = clip(String(raw).toLowerCase()
.replace(/^#+/, '')
.replace(/[_\s]+/g, ' ')
.replace(/[^\p{L}\p{N} +&'-]/gu, '')
.trim(), 40)
.trim();
if (tag && !GENERIC_TAGS.has(tag) && !tags.includes(tag)) tags.push(tag);
}
return tags.slice(0, 8);
}
const comparable = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
const stripQuotes = (s) => String(s).replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
// A suggested name, tidied; null when there isn't one or it's the current name
// written differently.
function cleanName(value, currentName) {
if (typeof value !== 'string') return null;
const name = clip(stripQuotes(value)
.replace(/\.(stl|3mf|obj|fbx|step|stp|3dm|gcode|zip|7z|rar)$/i, '')
.replace(/\s+/g, ' ')
.trim(), 80)
.trim();
return name && comparable(name) !== comparable(currentName) ? name : null;
}
// Up to 3 collection names, tidied, without repeats that differ only in case or punctuation.
function normalizeCollections(value) {
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
const names = [];
for (const raw of list) {
const name = clip(stripQuotes(raw).replace(/\s+/g, ' ').trim(), 60).trim();
if (name && !names.some((n) => comparable(n) === comparable(name))) names.push(name);
}
return names.slice(0, 3);
}
// The JSON object in a reply, allowing for code fences, notes around it, or braces
// in a note after it; null when there isn't one.
function extractJson(content) {
const text = String(content || '').replace(/```(?:json)?/gi, '').trim();
const candidates = [text];
const first = text.indexOf('{');
const last = text.lastIndexOf('}');
if (first >= 0 && last > first) candidates.push(text.slice(first, last + 1));
if (first >= 0) {
// The first complete {...}, reading past braces inside strings.
let depth = 0;
let inString = false;
let escaped = false;
for (let i = first; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
} else if (ch === '"') inString = true;
else if (ch === '{') depth += 1;
else if (ch === '}' && --depth === 0) { candidates.push(text.slice(first, i + 1)); break; }
}
}
for (const candidate of candidates) {
try {
const data = JSON.parse(candidate);
if (data && typeof data === 'object' && !Array.isArray(data)) return data;
} catch { /* try the next */ }
}
return null;
}
// Says what came back instead of the JSON, so a failure can be understood.
function badReplyMessage(content, finishReason) {
const text = String(content || '').replace(/\s+/g, ' ').trim();
if (!text) return 'The AI reply was empty, not the JSON asked for';
const cutOff = finishReason === 'length' ? ' (it was cut off)' : '';
return `The AI reply wasn’t the JSON asked for${cutOff}. It began: “${clip(text, 120)}${text.length > 120 ? '…' : ''}”`;
}
// The model's reply -> { tags, description, name, collections }. Tolerates code
// fences or prose around the JSON. Only the fields asked for are read: name when
// { currentName } is given, collections when { collections: true }.
function parseReply(content, options = {}) {
const { describe = true, collections = false } = options;
const data = extractJson(content);
if (!data) throw new ReplyError(badReplyMessage(content, options.finishReason));
const tags = describe ? normalizeTags(data.tags) : [];
const description = describe && typeof data.description === 'string' ? clip(data.description.trim(), 600) : '';
if (describe && !tags.length && !description) throw new ReplyError('The AI reply had no tags or description');
return {
tags,
description,
name: Object.prototype.hasOwnProperty.call(options, 'currentName') ? cleanName(data.name, options.currentName) : null,
collections: collections ? normalizeCollections(data.collections) : [],
};
}
function usageOf(usage) {
return {
prompt: Math.max(0, Math.round(Number(usage && usage.prompt_tokens) || 0)),
completion: Math.max(0, Math.round(Number(usage && usage.completion_tokens) || 0)),
};
}
// US dollars: what the provider reports (OpenRouter), else tokens × your prices
// per million tokens, else null when prices aren't known.
function costOf(usage, prices) {
if (usage && typeof usage.cost === 'number') return usage.cost;
if (!prices || prices.input == null || prices.output == null) return null;
const { prompt, completion } = usageOf(usage);
return (prompt * prices.input + completion * prices.output) / 1e6;
}
// Adds one response's usage to a running total, so retried requests are still counted.
function addUsage(total, usage) {
const { prompt, completion } = usageOf(usage);
total.prompt_tokens += prompt;
total.completion_tokens += completion;
if (usage && typeof usage.cost === 'number') total.cost = (total.cost || 0) + usage.cost;
}
async function chat(config, body, { timeoutMs = 90000 } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
if (config.provider === 'openrouter') headers['X-Title'] = 'Printhive';
let res;
try {
res = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
if (err.name === 'TimeoutError') throw new AiError('The AI service took too long to answer', 0, 'timeout');
throw new AiError(`Couldn't reach ${config.baseUrl}: ${(err.cause && err.cause.message) || err.message}`, 0, 'network');
}
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch { /* not JSON */ }
const detail = (data && data.error && (data.error.message || data.error)) || (data && data.message);
if (!res.ok) throw new AiError(`${res.status}: ${detail || text.slice(0, 200) || res.statusText}`, res.status);
// OpenRouter can report an upstream failure inside a 200.
if (data && data.error) throw new AiError(String(detail), Number(data.error.code) || 502);
if (!data || !Array.isArray(data.choices) || !data.choices.length) {
throw new AiError('The AI service sent back something unexpected', 502);
}
return data;
}
// Asks about one model: tags and a description, and optionally a clearer name and
// collection ideas. Adapts to what the model supports: if it refuses JSON mode or
// images, the request is retried without them (imageRejected tells the caller to
// stop sending images). A reply that isn't the JSON asked for is asked again, up
// to twice: with more room if it was cut off, and the last time without JSON mode,
// which can send back empty replies. Rate limits and server errors are retried
// with backoff. Errors carry the tokens and cost spent on the failed attempts.
async function describeModel(config, model, {
image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [],
} = {}) {
let useImage = Boolean(image);
let useJson = true;
let imageRejected = false;
let replyRetries = 0;
let maxTokens = 1500;
const spent = { prompt_tokens: 0, completion_tokens: 0, cost: null };
const withSpend = (err) => Object.assign(err, { usage: usageOf(spent), cost: costOf(spent, config.prices) });
const want = { describe, collections: suggestCollections, ...(suggestName ? { currentName: model.name } : {}) };
for (let attempt = 0; ; attempt++) {
const body = {
model: config.model,
messages: buildMessages(model, {
image: useImage ? image : null, knownTags, describe, suggestName, suggestCollections, collectionNames,
}),
temperature: 0.2,
max_tokens: maxTokens,
};
if (useJson) body.response_format = { type: 'json_object' };
if (config.provider === 'openrouter') body.usage = { include: true };
try {
const data = await chat(config, body);
addUsage(spent, data.usage);
const choice = data.choices[0] || {};
let reply;
try {
reply = parseReply((choice.message || {}).content, { ...want, finishReason: choice.finish_reason });
} catch (err) {
if (!(err instanceof ReplyError) || replyRetries >= 2) throw withSpend(err);
replyRetries += 1;
if (choice.finish_reason === 'length') maxTokens = Math.min(maxTokens * 2, 4000); // cut off: give it room
else if (replyRetries === 2) useJson = false;
continue;
}
return {
...reply,
usage: usageOf(spent),
cost: costOf(spent, config.prices),
imageUsed: useImage,
imageRejected,
replyRetried: replyRetries > 0,
};
} catch (err) {
if (!(err instanceof AiError) || attempt >= 5) throw withSpend(err);
if (err.status === 400 || err.status === 415 || err.status === 422) {
if (useImage && /image|vision|multimodal|modalit|content.?part|content type/i.test(err.message)) {
useImage = false; imageRejected = true; continue;
}
if (useJson) { useJson = false; continue; }
if (useImage) { useImage = false; imageRejected = true; continue; }
throw withSpend(err);
}
if (err.status === 429 || err.status >= 500 || err.kind === 'timeout') {
await sleep(Math.min(20000, 1500 * 2 ** attempt));
continue;
}
throw withSpend(err);
}
}
}
// Errors that will fail every request the same way, so a run stops instead of
// burning through the queue. null for errors worth moving past.
function fatalMessage(err) {
if (!(err instanceof AiError)) return null;
if (err.status === 401 || err.status === 403) return `Your API key was rejected (${err.message})`;
if (err.status === 402) return `Your AI account is out of credit (${err.message})`;
if (err.status === 404) return `The model or API address wasn't found (${err.message})`;
if (err.kind === 'network') return err.message;
return null;
}
// A rough cost for `count` models, from typical token counts.
function estimate(count, config) {
const prompt = count * (config.sendImages ? 1400 : 500);
const completion = count * 120;
const cost = costOf({ prompt_tokens: prompt, completion_tokens: completion }, config.prices);
return { promptTokens: prompt, completionTokens: completion, cost };
}
module.exports = {
PROVIDERS, AiError, ReplyError, configFrom, missingConfig, buildMessages, parseReply, normalizeTags,
normalizeCollections, costOf, describeModel, fatalMessage, estimate,
};

287
lib/aiRuns.js Normal file
View file

@ -0,0 +1,287 @@
// AI runs: a queue of models handled a few at a time. Progress, tokens and cost
// live in the database, so a run can be watched, stopped and resumed, and a
// server restart pauses it instead of spending on its own.
const fs = require('fs');
const path = require('path');
const db = require('./db');
const store = require('./store');
const ai = require('./ai');
const fingerprint = require('./fingerprint');
const CONCURRENCY = 3;
const MAX_FAILURES_IN_A_ROW = 5;
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
let resolveConfig = async () => ai.configFrom({});
let working = false;
let rerun = false;
const httpError = (message, status) => Object.assign(new Error(message), { status });
async function init(resolver) {
resolveConfig = resolver;
await db.query(
`UPDATE ai_runs SET status = 'paused', note = 'Paused when the server restarted. Resume to carry on.'
WHERE status = 'running'`);
}
// The model's thumbnail as a data URL, when there is one small enough to send.
function thumbnailDataUrl(model) {
if (!model.thumbnail_path) return null;
const abs = path.resolve(model.thumbnail_path);
const type = IMAGE_TYPES[path.extname(abs).toLowerCase()];
try {
if (!type || fs.statSync(abs).size > MAX_IMAGE_BYTES) return null;
return `data:${type};base64,${fs.readFileSync(abs).toString('base64')}`;
} catch {
return null;
}
}
// Names of the files inside a model's archives, as extra context.
async function entryNames(model) {
const { rows } = await db.query(
`SELECT e.path FROM archive_entries e JOIN files f ON f.id = e.file_id
WHERE f.model_id = $1 ORDER BY e.id LIMIT 60`, [model.id]);
return rows.map((r) => r.path.split('/').pop()).filter(Boolean);
}
// options: describe (tags + descriptions, default on), overwriteDescriptions,
// renameUnclear, suggestCollections, maxCost.
async function createRun(modelIds, options = {}) {
const config = await resolveConfig();
const problem = ai.missingConfig(config);
if (problem) throw httpError(problem, 400);
const tasks = {
describe: options.describe !== false,
renameUnclear: Boolean(options.renameUnclear),
suggestCollections: Boolean(options.suggestCollections),
};
if (!tasks.describe && !tasks.renameUnclear && !tasks.suggestCollections) {
throw httpError('Choose at least one thing for the AI to do', 400);
}
const ids = [...new Set((modelIds || []).map(Number).filter(Number.isInteger))];
const maxCost = Number(options.maxCost) > 0 ? Number(options.maxCost) : null;
const run = await db.transaction(async (client) => {
const { rows: [created] } = await client.query(
`INSERT INTO ai_runs (provider, model, options) VALUES ($1, $2, $3) RETURNING id`,
[config.label, config.model, {
...tasks,
overwriteDescriptions: tasks.describe && Boolean(options.overwriteDescriptions),
maxCost,
pricesKnown: config.prices.input != null || config.provider === 'openrouter',
}]);
const { rowCount } = await client.query(
`INSERT INTO ai_run_items (run_id, model_id)
SELECT $1, id FROM models WHERE id = ANY($2::int[]) AND deleted_at IS NULL`, [created.id, ids]);
if (!rowCount) throw httpError('No models to tag', 400);
await client.query('UPDATE ai_runs SET total = $2 WHERE id = $1', [created.id, rowCount]);
return created;
});
kick();
return getRun(run.id);
}
async function finish(runId, status, error = null) {
await db.query(
`UPDATE ai_runs SET status = $2, error = $3, finished_at = now() WHERE id = $1 AND status = 'running'`,
[runId, status, error]);
}
async function tagOne(runId, modelId, config, context) {
const { images, knownTags, collectionNames, tasks } = context;
const model = await store.getModel(modelId);
if (!model || model.deleted_at) {
await db.query(`UPDATE ai_run_items SET status = 'error', error = 'The model was deleted', updated_at = now()
WHERE run_id = $1 AND model_id = $2`, [runId, modelId]);
await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]);
return { ok: true };
}
try {
model.entryNames = await entryNames(model);
const result = await ai.describeModel(config, model, {
image: images ? thumbnailDataUrl(model) : null,
knownTags,
describe: tasks.describe,
suggestName: tasks.renameUnclear,
suggestCollections: tasks.suggestCollections,
collectionNames,
});
const name = tasks.renameUnclear ? result.name : null;
await store.applyAiResult(modelId, {
tags: result.tags,
description: result.description,
overwriteDescription: tasks.overwriteDescriptions,
name,
markTagged: tasks.describe,
});
const suggested = tasks.suggestCollections ? await store.suggestCollections(modelId, result.collections) : [];
await db.query(
`UPDATE ai_run_items SET status = 'done', tags = $3, error = NULL, renamed_from = $4, renamed_to = $5,
collections = $6, updated_at = now() WHERE run_id = $1 AND model_id = $2`,
[runId, modelId, tasks.describe ? result.tags : null, name ? model.name : null, name, suggested]);
await db.query(
`UPDATE ai_runs SET done = done + 1, prompt_tokens = prompt_tokens + $2,
completion_tokens = completion_tokens + $3, cost_usd = cost_usd + $4 WHERE id = $1`,
[runId, result.usage.prompt, result.usage.completion, result.cost || 0]);
return { ok: true, imageRejected: result.imageRejected, suggested: suggested.length > 0 };
} catch (err) {
// Failed attempts still cost tokens.
if (err.usage && (err.usage.prompt || err.usage.completion)) {
await db.query(
`UPDATE ai_runs SET prompt_tokens = prompt_tokens + $2, completion_tokens = completion_tokens + $3,
cost_usd = cost_usd + $4 WHERE id = $1`,
[runId, err.usage.prompt, err.usage.completion, err.cost || 0]);
}
const fatal = ai.fatalMessage(err);
if (fatal) return { ok: false, fatal }; // left pending, so resuming retries it
const message = err.message.slice(0, 300);
await db.query(`UPDATE ai_run_items SET status = 'error', error = $3, updated_at = now()
WHERE run_id = $1 AND model_id = $2`, [runId, modelId, message]);
await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]);
return { ok: false, error: message };
}
}
async function processRun(runId) {
const config = await resolveConfig();
const problem = ai.missingConfig(config);
if (problem) { await finish(runId, 'failed', problem); return; }
const knownTags = await store.topTags(150);
let images = config.sendImages;
let failuresInARow = 0;
let lastError = null;
let collectionNames = null;
for (;;) {
const { rows: [run] } = await db.query('SELECT status, options, cost_usd FROM ai_runs WHERE id = $1', [runId]);
if (!run || run.status !== 'running') return; // stopped, or deleted
const options = run.options || {};
// Runs from before these options existed only tagged and described.
const tasks = {
describe: options.describe !== false,
overwriteDescriptions: Boolean(options.overwriteDescriptions),
renameUnclear: Boolean(options.renameUnclear),
suggestCollections: Boolean(options.suggestCollections),
};
const { rows: items } = await db.query(
`SELECT model_id FROM ai_run_items WHERE run_id = $1 AND status = 'pending' ORDER BY model_id LIMIT $2`,
[runId, CONCURRENCY]);
if (!items.length) { await finish(runId, 'done'); return; }
// Checked between batches, so a run can go over by up to one batch.
if (options.maxCost && Number(run.cost_usd) >= options.maxCost) {
await finish(runId, 'stopped', `Stopped at your $${options.maxCost} spending limit`);
return;
}
// Existing collections and earlier ideas, so suggestions reuse the same names.
if (tasks.suggestCollections && collectionNames === null) collectionNames = await store.collectionNameHints();
const results = await Promise.all(items.map((item) =>
tagOne(runId, item.model_id, config, { images, knownTags, collectionNames: collectionNames || [], tasks })));
// New tags, descriptions and names change a model's search text, so refresh the index.
if (results.some((r) => r.ok)) fingerprint.kick();
if (results.some((r) => r.suggested)) collectionNames = null;
for (const r of results) {
if (r.imageRejected && images) {
images = false;
await db.query('UPDATE ai_runs SET note = $2 WHERE id = $1',
[runId, `${config.model} doesn't accept images, so models are judged from their names and files.`]);
}
if (r.fatal) { await finish(runId, 'failed', r.fatal); return; }
if (r.ok) failuresInARow = 0;
else { failuresInARow += 1; lastError = r.error; }
}
if (failuresInARow >= MAX_FAILURES_IN_A_ROW) {
await finish(runId, 'failed', `Stopped after ${failuresInARow} failures in a row. Last: ${lastError}`);
return;
}
}
}
// Works through running runs, one at a time. Safe to call often.
function kick() {
if (working) { rerun = true; return; }
working = true;
(async () => {
for (;;) {
const { rows: [run] } = await db.query(`SELECT id FROM ai_runs WHERE status = 'running' ORDER BY id LIMIT 1`);
if (!run) return;
try {
await processRun(run.id);
} catch (err) {
console.error(`AI run ${run.id} failed:`, err.message);
await finish(run.id, 'failed', err.message.slice(0, 300));
}
}
})()
.catch((err) => console.error('AI runs stopped:', err.message))
.finally(() => {
working = false;
if (rerun) { rerun = false; kick(); }
});
}
const shapeRun = (r) => r && ({
...r,
prompt_tokens: Number(r.prompt_tokens),
completion_tokens: Number(r.completion_tokens),
cost_usd: Number(r.cost_usd),
});
async function listRuns(limit = 15) {
const { rows } = await db.query('SELECT * FROM ai_runs ORDER BY id DESC LIMIT $1', [limit]);
return rows.map(shapeRun);
}
// A run with its latest results and errors, for watching it.
async function getRun(id) {
const { rows: [run] } = await db.query('SELECT * FROM ai_runs WHERE id = $1', [id]);
if (!run) return null;
const { rows: recent } = await db.query(
`SELECT i.model_id, m.name, i.status, i.tags, i.error, i.renamed_from, i.renamed_to, i.collections
FROM ai_run_items i LEFT JOIN models m ON m.id = i.model_id
WHERE i.run_id = $1 AND i.status <> 'pending' ORDER BY i.updated_at DESC LIMIT 12`, [id]);
// Every failure, not only the ones still inside that window: a model that
// failed early would otherwise scroll out of sight with no way to find it.
const { rows: failures } = await db.query(
`SELECT i.model_id, m.name, i.error
FROM ai_run_items i LEFT JOIN models m ON m.id = i.model_id
WHERE i.run_id = $1 AND i.status = 'error' ORDER BY i.updated_at DESC LIMIT 200`, [id]);
return { ...shapeRun(run), recent, failures };
}
async function stop(id) {
const { rowCount } = await db.query(
`UPDATE ai_runs SET status = 'stopped', finished_at = now(), note = NULL
WHERE id = $1 AND status IN ('running', 'paused')`, [id]);
return rowCount > 0;
}
// Carries on a paused, stopped or failed run, retrying models that errored.
async function resume(id) {
const resumed = await db.transaction(async (client) => {
const { rows: [run] } = await client.query(
`SELECT id FROM ai_runs WHERE id = $1 AND status IN ('paused', 'stopped', 'failed', 'done') FOR UPDATE`, [id]);
if (!run) return false;
const retried = await client.query(
`UPDATE ai_run_items SET status = 'pending', error = NULL WHERE run_id = $1 AND status = 'error'`, [id]);
const { rows: [left] } = await client.query(
`SELECT COUNT(*)::int AS n FROM ai_run_items WHERE run_id = $1 AND status = 'pending'`, [id]);
if (!left.n) return false;
await client.query(
`UPDATE ai_runs SET status = 'running', error = NULL, note = NULL, finished_at = NULL,
failed = GREATEST(failed - $2, 0) WHERE id = $1`, [id, retried.rowCount]);
return true;
});
if (resumed) kick();
return resumed;
}
async function hasActiveRun() {
const { rows } = await db.query(`SELECT 1 FROM ai_runs WHERE status = 'running' LIMIT 1`);
return rows.length > 0;
}
module.exports = { init, createRun, listRuns, getRun, stop, resume, kick, hasActiveRun, thumbnailDataUrl };

View file

@ -73,6 +73,6 @@ function hashResetToken(token) {
} }
module.exports = { module.exports = {
COOKIE, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie, COOKIE, getSecret, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
currentUser, requireAuth, makeResetToken, hashResetToken, currentUser, requireAuth, makeResetToken, hashResetToken,
}; };

155
lib/autotag.js Normal file
View file

@ -0,0 +1,155 @@
// Automatic tagging: strict whole-word rules over model names, file names, and the
// names (and immediate folders) of files inside archives. Rule tags are recorded
// as such (model_tags.source = 'rule'), so a pass only adds or removes its own
// tags; ones you typed are never touched.
const crypto = require('crypto');
const db = require('./db');
const store = require('./store');
// Bump when the built-in rules change, so every model is checked again.
const RULES_VERSION = 1;
const BUILT_IN_RULES = [
{ group: 'Part types', tag: 'bracket', phrases: ['bracket'] },
{ group: 'Part types', tag: 'holder', phrases: ['holder'] },
{ group: 'Part types', tag: 'mount', phrases: ['mount'] },
{ group: 'Part types', tag: 'hinge', phrases: ['hinge'] },
{ group: 'Part types', tag: 'drawer', phrases: ['drawer'] },
{ group: 'Part types', tag: 'baseplate', phrases: ['baseplate', 'base plate'] },
{ group: 'Part types', tag: 'hook', phrases: ['hook'] },
{ group: 'Part types', tag: 'clip', phrases: ['clip'] },
{ group: 'Part types', tag: 'organizer', phrases: ['organizer', 'organiser'] },
{ group: 'Part types', tag: 'keychain', phrases: ['keychain', 'key chain', 'keyring', 'key ring'] },
{ group: 'Part types', tag: 'gridfinity', phrases: ['gridfinity'] },
{ group: 'Mechanisms', tag: 'print-in-place', phrases: ['print in place', 'printinplace', 'pip'] },
{ group: 'Mechanisms', tag: 'articulated', phrases: ['articulated', 'flexi'] },
{ group: 'Mechanisms', tag: 'threaded', phrases: ['threaded', 'thread'] },
{ group: 'Mechanisms', tag: 'gear', phrases: ['gear'] },
{ group: 'Materials', tag: 'pla', phrases: ['pla'] },
{ group: 'Materials', tag: 'petg', phrases: ['petg'] },
{ group: 'Materials', tag: 'abs', phrases: ['abs'] },
{ group: 'Materials', tag: 'asa', phrases: ['asa'] },
{ group: 'Materials', tag: 'tpu', phrases: ['tpu'] },
{ group: 'Materials', tag: 'nylon', phrases: ['nylon'] },
{ group: 'Techniques', tag: 'vase mode', phrases: ['vase mode', 'vasemode', 'spiral vase'] },
{ group: 'Techniques', tag: 'fuzzy skin', phrases: ['fuzzy skin', 'fuzzyskin'] },
{ group: 'Techniques', tag: 'multicolor', phrases: ['multicolor', 'multicolour', 'multi color', 'multi colour'] },
];
// Words in a name: splits on punctuation, camelCase and letter/number boundaries.
// "PETGWallHook_v2.stl" -> ["petg", "wall", "hook", "v", "2", "stl"]
function tokenize(text) {
return String(text || '')
.replace(/(?<=[a-z])(?=[A-Z])/g, ' ')
.replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, ' ')
.replace(/(?<=[A-Za-z])(?=\d)|(?<=\d)(?=[A-Za-z])/g, ' ')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
const sameWord = (token, word) => token === word || token === `${word}s` || token === `${word}es`;
function hasPhrase(tokens, words) {
if (!words.length) return false;
for (let i = 0; i + words.length <= tokens.length; i++) {
if (words.every((w, j) => sameWord(tokens[i + j], w))) return true;
}
return false;
}
const normalizeTag = (tag) => String(tag).trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 40);
// rules: [{ tag, phrases }] -> compiled once for matching.
function compile(rules) {
return rules.map((r) => ({ tag: normalizeTag(r.tag), phrases: r.phrases.map(tokenize).filter((w) => w.length) }));
}
const COMPILED_BUILT_INS = compile(BUILT_IN_RULES);
// Tags for a set of names (each matched separately, so a phrase never spans two).
function matchTags(texts, customRules = []) {
const lists = (Array.isArray(texts) ? texts : [texts]).map(tokenize);
const rules = [...COMPILED_BUILT_INS, ...compile(customRules.map((r) => ({ tag: r.tag, phrases: [r.phrase] })))];
const tags = new Set();
for (const rule of rules) {
if (rule.tag && rule.phrases.some((words) => lists.some((tokens) => hasPhrase(tokens, words)))) tags.add(rule.tag);
}
return [...tags];
}
// ---------------------------------------------------------------- database
async function isEnabled() {
return (await store.getSetting('autotag_enabled')) !== 'false';
}
async function listRules() {
const { rows } = await db.query('SELECT id, phrase, tag FROM tag_rules ORDER BY id');
return rows;
}
const saltFor = (rules) => crypto.createHash('md5').update(JSON.stringify([RULES_VERSION, rules])).digest('hex');
// Models whose names, files or archive contents changed since their rule tags
// were worked out (or whose rules changed).
const PENDING = `
FROM (
SELECT m.id, m.name, m.autotag_key,
COALESCE((SELECT string_agg(f.original_name, E'\\n' ORDER BY f.id) FROM files f WHERE f.model_id = m.id), '') AS files,
COALESCE((SELECT string_agg(e.path, E'\\n' ORDER BY e.id)
FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = m.id), '') AS entries
FROM models m
) d
WHERE d.autotag_key IS DISTINCT FROM md5($1 || d.name || E'\\n' || d.files || E'\\n' || d.entries)`;
async function run() {
if (!(await isEnabled())) return false;
const rules = await listRules();
const salt = saltFor(rules.map((r) => [r.phrase, r.tag]));
const { rows } = await db.query(`SELECT d.id, d.name, d.files, d.entries ${PENDING} ORDER BY d.id LIMIT 100`, [salt]);
if (!rows.length) return false;
for (const row of rows) {
const entryNames = row.entries.split('\n').filter(Boolean).map((p) => p.split('/').slice(-2).join('/'));
const texts = [row.name, ...row.files.split('\n'), ...entryNames.flatMap((p) => p.split('/'))];
await store.replaceRuleTags(row.id, matchTags(texts, rules));
await db.query(`UPDATE models SET autotag_key = md5($2 || $3 || E'\\n' || $4 || E'\\n' || $5) WHERE id = $1`,
[row.id, salt, row.name, row.files, row.entries]);
}
return true;
}
async function pendingCount() {
if (!(await isEnabled())) return 0;
const rules = await listRules();
const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`, [saltFor(rules.map((r) => [r.phrase, r.tag]))]);
return rows[0].n;
}
// Checks every model again on the next pass.
async function reapply() {
await db.query('UPDATE models SET autotag_key = NULL');
}
// Turning it off keeps the tags already applied unless removeTags is set.
async function setEnabled(enabled, { removeTags = false } = {}) {
await store.setSetting('autotag_enabled', enabled ? 'true' : 'false');
await reapply();
return !enabled && removeTags ? store.removeTagsFrom('rule') : 0;
}
async function addRule(phrase, tag) {
const cleanPhrase = String(phrase || '').trim().slice(0, 80);
const cleanTag = normalizeTag(tag || '');
if (!tokenize(cleanPhrase).length) throw Object.assign(new Error('Enter a word or phrase to look for'), { status: 400 });
if (!cleanTag) throw Object.assign(new Error('Enter the tag to add'), { status: 400 });
await db.query('INSERT INTO tag_rules (phrase, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING', [cleanPhrase, cleanTag]);
}
async function removeRule(id) {
await db.query('DELETE FROM tag_rules WHERE id = $1', [id]);
}
module.exports = {
BUILT_IN_RULES, tokenize, matchTags,
run, pendingCount, reapply, isEnabled, setEnabled, listRules, addRule, removeRule,
};

286
lib/collectionWatch.js Normal file
View file

@ -0,0 +1,286 @@
// Watches a MakerWorld collection and imports models added to it. A check reads
// the collection page (with your token, so private collections work), imports the
// models it hasn't seen before, and remembers each one so it isn't imported twice.
// The site is read gently: a few models per check, spaced out.
const db = require('./db');
const store = require('./store');
const downloadBlock = require('./downloadBlock');
const makerworld = require('./importers/makerworld');
const IMPORT_LIMIT = 15; // models per check, so a big collection trickles in
const BETWEEN_IMPORTS_MS = 3000; // FlareSolverr runs one browser request at a time
// Downloads need much more room: MakerWorld refuses bursts a few seconds apart,
// but 67 in a row about 20 seconds apart went through without one refusal.
const BETWEEN_DOWNLOADS_MS = 20000;
const MORE_SOON_MINUTES = 10; // when a check hit the limit, come back sooner
const TICK_MS = 5 * 60 * 1000;
const INTERVALS = [1, 3, 6, 12, 24, 48];
// { fetchCollection(url), importFromUrl(url, opts), startAiRun(modelIds) }
let deps = null;
let timer = null;
let busy = false;
const checking = new Set(); // watch ids being checked right now, so checks can't overlap
const httpError = (message, status, reason) => Object.assign(new Error(message), { status, reason });
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function init(dependencies) {
deps = dependencies;
}
// Checks anything due, every few minutes.
function start() {
// A check that was running when the server stopped isn't running now.
db.query(`UPDATE watched_collections SET last_status = NULL WHERE last_status = 'checking'`)
.catch((err) => console.warn('Could not clear stale watch checks:', err.message));
// Imports recorded before 'partial' existed: a model with an error kept none of its files.
db.query(`UPDATE watched_collection_items SET status = 'partial' WHERE status = 'imported' AND error IS NOT NULL`)
.catch((err) => console.warn('Could not mark file-less imports:', err.message));
clearInterval(timer);
timer = setInterval(() => { runDue().catch((err) => console.error('Collection watch failed:', err.message)); }, TICK_MS);
if (timer.unref) timer.unref();
runDue().catch((err) => console.error('Collection watch failed:', err.message));
}
async function list() {
const { rows } = await db.query(`
SELECT w.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count,
COALESCE(r.recent, '[]'::json) AS recent,
COALESCE(q.pending, '[]'::json) AS pending
FROM watched_collections w
LEFT JOIN projects p ON p.id = w.project_id
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('design_id', i.design_id, 'model_id', i.model_id, 'title', i.title,
'status', i.status, 'error', i.error, 'seen_at', i.seen_at)
ORDER BY i.seen_at DESC) AS recent
FROM (SELECT * FROM watched_collection_items WHERE watch_id = w.id ORDER BY seen_at DESC LIMIT 5) i
) r ON true
-- Every model still waiting for its files, not just the five most recent.
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('model_id', i.model_id, 'title', i.title) ORDER BY i.seen_at DESC) AS pending
FROM (SELECT * FROM watched_collection_items
WHERE watch_id = w.id AND status = 'partial' AND model_id IS NOT NULL
ORDER BY seen_at DESC LIMIT 200) i
) q ON true
ORDER BY w.id`);
return rows;
}
async function get(id) {
const { rows: [watch] } = await db.query(`
SELECT w.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count
FROM watched_collections w LEFT JOIN projects p ON p.id = w.project_id WHERE w.id = $1`, [id]);
if (!watch) return null;
const { rows: recent } = await db.query(`
SELECT i.design_id, i.model_id, i.title, i.status, i.error, i.seen_at, m.name AS model_name
FROM watched_collection_items i LEFT JOIN models m ON m.id = i.model_id
WHERE i.watch_id = $1 ORDER BY i.seen_at DESC LIMIT 12`, [id]);
return { ...watch, recent };
}
// Adds a watch. Reads the collection first, so a bad link or a refused token is
// reported now rather than silently later.
async function add({ url, aiTag = false, intervalHours = 6, fetchFiles = false }) {
const sourceId = makerworld.collectionIdFromUrl(url);
if (!sourceId) throw httpError('That isn’t a MakerWorld collection link (makerworld.com/…/collections/…).', 400);
const { rowCount } = await db.query('SELECT 1 FROM watched_collections WHERE site = $1 AND source_id = $2', ['makerworld', sourceId]);
if (rowCount) throw httpError('You’re already watching that collection.', 409);
const collection = await deps.fetchCollection(url);
const project = await store.findOrCreateProject(collection.title || `MakerWorld collection ${sourceId}`);
const hours = INTERVALS.includes(Number(intervalHours)) ? Number(intervalHours) : 6;
const { rows: [watch] } = await db.query(
`INSERT INTO watched_collections (site, source_id, url, title, project_id, ai_tag, interval_hours, total, listed, last_status, fetch_files)
VALUES ('makerworld', $1, $2, $3, $4, $5, $6, $7, $8, 'checking', $9) RETURNING id`,
[sourceId, url, collection.title, project.id, Boolean(aiTag), hours, collection.total, collection.designs.length,
Boolean(fetchFiles)]);
// The collection was just read, so import straight from it rather than
// fetching the whole thing again.
const added = await get(watch.id);
checking.add(watch.id);
importFound(added, collection)
.catch((err) => console.warn(`Watch ${watch.id} first import failed:`, err.message))
.finally(() => checking.delete(watch.id));
return added;
}
async function update(id, { enabled, aiTag, intervalHours, fetchFiles }) {
const hours = INTERVALS.includes(Number(intervalHours)) ? Number(intervalHours) : null;
const { rowCount } = await db.query(
`UPDATE watched_collections SET
enabled = COALESCE($2, enabled),
ai_tag = COALESCE($3, ai_tag),
interval_hours = COALESCE($4, interval_hours),
fetch_files = COALESCE($5, fetch_files),
next_check_at = CASE WHEN $2 IS TRUE THEN now() ELSE next_check_at END
WHERE id = $1`,
[id, enabled === undefined ? null : Boolean(enabled), aiTag === undefined ? null : Boolean(aiTag), hours,
fetchFiles === undefined ? null : Boolean(fetchFiles)]);
if (!rowCount) return null;
if (enabled) runDue().catch(() => {});
return get(id);
}
// Stops watching. Models already imported stay in the library.
async function remove(id) {
const { rowCount } = await db.query('DELETE FROM watched_collections WHERE id = $1', [id]);
return rowCount > 0;
}
// Checks one collection now: reads it (awaited, so problems come back to the
// caller), then imports in the background.
async function checkNow(id) {
const watch = await get(id);
if (!watch) throw httpError('Not watching that collection.', 404);
if (checking.has(id)) return watch; // already going; don't import anything twice
let collection;
try {
collection = await deps.fetchCollection(watch.url);
} catch (err) {
await recordFailure(watch, err);
throw err;
}
await db.query(
`UPDATE watched_collections SET title = $2, total = $3, listed = $4, last_status = 'checking',
last_error = NULL, last_error_reason = NULL WHERE id = $1`,
[id, collection.title || watch.title, collection.total, collection.designs.length]);
checking.add(id);
importFound(watch, collection)
.catch((err) => console.error(`Watch ${id} import failed:`, err.message))
.finally(() => checking.delete(id));
return get(id);
}
async function recordFailure(watch, err) {
await db.query(
`UPDATE watched_collections SET last_checked_at = now(), last_status = 'error', last_error = $2,
last_error_reason = $3, next_check_at = now() + make_interval(hours => GREATEST(interval_hours, 1))
WHERE id = $1`,
[watch.id, String(err.message).slice(0, 300), err.reason || null]);
}
// Imports the models this watch hasn't seen yet, a few at a time.
async function importFound(watch, collection) {
const { rows: seenRows } = await db.query(
`SELECT design_id FROM watched_collection_items WHERE watch_id = $1 AND status <> 'error'`, [watch.id]);
const seen = new Set(seenRows.map((r) => r.design_id));
const pending = collection.designs.filter((d) => !seen.has(d.id));
const todo = pending.slice(0, IMPORT_LIMIT);
let added = 0;
let problem = null;
let fileMisses = 0; // downloads refused in a row
let stoppedEarly = false; // left models unimported, so come back soon
const fresh = [];
for (const design of todo) {
// Paused while this batch was running: stop, rather than import the rest.
const { rows: [live] } = await db.query('SELECT enabled FROM watched_collections WHERE id = $1', [watch.id]);
if (!live || !live.enabled) break;
const url = makerworld.designUrl(design);
// While MakerWorld is waiting for you to prove you're not a robot, the
// check still brings in the models — their files wait for you. Decided out
// here because the wait after each import depends on it.
const withFiles = Boolean(watch.fetch_files) && !downloadBlock.isBlocked('makerworld');
try {
const result = await deps.importFromUrl(url, {
projects: watch.project_id ? [watch.project_id] : [],
withFiles, // off by default: MakerWorld refuses quick bursts
});
if (result.existing) {
if (watch.project_id) await store.addModelToProject(result.model.id, watch.project_id);
await recordItem(watch.id, design, result.model.id, 'existing', null);
} else {
added += 1;
fresh.push(result.model.id);
// Without files (or when they couldn't be fetched) the model is only
// 'partial' — "Get files" fills it in later.
const gotFiles = withFiles && !result.filesError;
await recordItem(watch.id, design, result.model.id, gotFiles ? 'imported' : 'partial',
result.filesError || null);
}
// Refused downloads mean we're asking too fast. Stop the batch and come
// back shortly, rather than collecting refusals for the whole of it.
if (withFiles && result.filesError) {
fileMisses += 1;
if (fileMisses >= 2 && result.filesErrorReason !== 'makerworld_token') { stoppedEarly = true; break; }
} else if (!result.filesError) {
fileMisses = 0;
}
// A refused token, or a check only you can solve, means every later
// download fails too: stop and say so.
if (['makerworld_token', 'makerworld_challenge'].includes(result.filesErrorReason)) {
problem = httpError(result.filesError, 502, result.filesErrorReason);
break;
}
} catch (err) {
await recordItem(watch.id, design, null, 'error', err.message);
if (['makerworld_token', 'makerworld_challenge'].includes(err.reason)) { problem = err; break; }
}
await sleep(withFiles ? BETWEEN_DOWNLOADS_MS : BETWEEN_IMPORTS_MS);
}
const more = pending.length > todo.length || stoppedEarly;
if (problem) {
await recordFailure({ ...watch, id: watch.id }, problem);
} else {
await db.query(
`UPDATE watched_collections SET last_checked_at = now(), last_status = 'ok', last_error = NULL,
last_error_reason = NULL, last_added = $2,
next_check_at = now() + CASE WHEN $3::boolean THEN make_interval(mins => $4) ELSE make_interval(hours => interval_hours) END
WHERE id = $1`,
[watch.id, added, more, MORE_SOON_MINUTES]);
}
if (watch.ai_tag && fresh.length && deps.startAiRun) {
await deps.startAiRun(fresh).catch((err) => console.warn(`Watch ${watch.id}: AI tagging didn't start:`, err.message));
}
}
async function recordItem(watchId, design, modelId, status, error) {
await db.query(
`INSERT INTO watched_collection_items (watch_id, design_id, model_id, title, status, error, seen_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (watch_id, design_id) DO UPDATE SET model_id = EXCLUDED.model_id, title = EXCLUDED.title,
status = EXCLUDED.status, error = EXCLUDED.error, seen_at = now()`,
[watchId, design.id, modelId, String(design.title || '').slice(0, 200), status, error ? String(error).slice(0, 300) : null]);
}
// Checks every watch that's due, one at a time.
async function runDue() {
if (busy || !deps) return;
busy = true;
try {
for (;;) {
const { rows: [due] } = await db.query(
`SELECT id FROM watched_collections WHERE enabled AND next_check_at <= now()
AND id <> ALL($1::int[]) ORDER BY next_check_at LIMIT 1`, [[...checking]]);
if (!due) return;
// checkNow leaves the import running in the background; wait for it here so
// watches are checked one after another.
const watch = await get(due.id);
checking.add(watch.id);
try {
const collection = await deps.fetchCollection(watch.url);
await db.query(
`UPDATE watched_collections SET title = $2, total = $3, listed = $4, last_status = 'checking' WHERE id = $1`,
[watch.id, collection.title || watch.title, collection.total, collection.designs.length]);
await importFound(watch, collection);
} catch (err) {
await recordFailure(watch, err);
} finally {
checking.delete(watch.id);
}
}
} finally {
busy = false;
}
}
module.exports = { INTERVALS, init, start, list, get, add, update, remove, checkNow, runDue };

View file

@ -34,4 +34,14 @@ async function readProjectInfo(inputPath, imagesDir) {
'reading this 3MF project'); 'reading this 3MF project');
} }
module.exports = { convert3mfToGlb, readProjectInfo }; // Just the filaments a project asks for: no --images, so no plate previews are
// written, and queued behind anything someone is waiting on — this runs over the
// whole library in the background.
async function readFilaments(inputPath) {
const info = await parseResult(
await runScript('threemf_info.py', [inputPath], { timeout: 60000, isBackground: true }),
'reading this 3MF project');
return Array.isArray(info.filaments) ? info.filaments : [];
}
module.exports = { convert3mfToGlb, readProjectInfo, readFilaments };

View file

@ -21,7 +21,12 @@ function query(text, params) {
} }
// Run a function inside a transaction, passing it a dedicated client. // Run a function inside a transaction, passing it a dedicated client.
async function transaction(fn) { // Postgres breaks a deadlock by killing one of the transactions. The victim
// rolled back and changed nothing, so running it again is safe — and usually
// works, because the transaction it collided with has since finished.
const RETRY_CODES = new Set(['40P01', '40001']); // deadlock_detected, serialization_failure
async function transaction(fn, attempt = 0) {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
@ -29,11 +34,14 @@ async function transaction(fn) {
await client.query('COMMIT'); await client.query('COMMIT');
return result; return result;
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK').catch(() => {}); // the connection may be gone
throw err; if (!RETRY_CODES.has(err.code) || attempt >= 3) throw err;
console.warn(`Database ${err.code}, retrying the transaction (attempt ${attempt + 2}).`);
} finally { } finally {
client.release(); client.release(); // before the pause below, so a retry doesn't hold a connection
} }
await new Promise((r) => setTimeout(r, 50 * (attempt + 1) ** 2));
return transaction(fn, attempt + 1);
} }
module.exports = { pool, query, transaction, init }; module.exports = { pool, query, transaction, init };

29
lib/downloadBlock.js Normal file
View file

@ -0,0 +1,29 @@
// When a site blocks downloads until you prove you're not a robot.
//
// MakerWorld does this after a few downloads in a row. Only you can clear it —
// by solving the check in your own browser — so Printhive stops asking until
// you say you have. Attempts while blocked are wasted and may prolong it.
//
// Held in memory: a restart clears it, and the next attempt finds out again.
const blocks = new Map(); // site → { since, message }
const DEFAULT = 'MakerWorld is asking you to confirm you’re not a robot before it will allow more downloads.';
function block(site, message = DEFAULT) {
if (!blocks.has(site)) blocks.set(site, { since: new Date(), message });
return blocks.get(site);
}
// Called when you say you've solved it, and whenever a download succeeds.
function clear(site) {
return blocks.delete(site);
}
const isBlocked = (site) => blocks.has(site);
function state(site) {
const b = blocks.get(site);
return b ? { blocked: true, since: b.since, message: b.message } : { blocked: false };
}
module.exports = { block, clear, isBlocked, state, DEFAULT };

46
lib/embeddingModel.js Normal file
View file

@ -0,0 +1,46 @@
// The local text-embedding model behind semantic search: bge-small-en-v1.5
// (quantized, ~34 MB) run with transformers.js. Nothing is sent anywhere; the model
// is downloaded once into MODELS_DIR (the Docker image ships with it).
const path = require('path');
const MODEL = 'Xenova/bge-small-en-v1.5';
const DIMENSIONS = 384;
const CACHE_DIR = process.env.MODELS_DIR || path.join(__dirname, '..', 'models');
// bge models match short queries against passages best with this instruction.
const QUERY_PREFIX = 'Represent this sentence for searching relevant passages: ';
const IDLE_MS = 10 * 60 * 1000;
let pipelinePromise = null;
let idleTimer = null;
// Loaded on first use and released after a while unused (it holds ~150 MB).
function load() {
if (!pipelinePromise) {
pipelinePromise = (async () => {
const { pipeline, env } = await import('@huggingface/transformers');
env.cacheDir = CACHE_DIR;
return pipeline('feature-extraction', MODEL, { dtype: 'q8' });
})();
pipelinePromise.catch(() => { pipelinePromise = null; });
}
clearTimeout(idleTimer);
idleTimer = setTimeout(release, IDLE_MS);
idleTimer.unref();
return pipelinePromise;
}
async function release() {
const loaded = pipelinePromise;
pipelinePromise = null;
try { await (await loaded).dispose(); } catch { /* already gone */ }
}
// texts -> unit-length vectors (arrays of numbers).
async function embed(texts) {
if (!texts.length) return [];
const extractor = await load();
const output = await extractor(texts, { pooling: 'cls', normalize: true });
return output.tolist();
}
module.exports = { MODEL, DIMENSIONS, QUERY_PREFIX, load, embed };

88
lib/embeddings.js Normal file
View file

@ -0,0 +1,88 @@
// Semantic search index: each model's text (name, description, notes, tags, file
// names) embedded locally, refreshed in the background whenever that text changes.
const db = require('./db');
const embeddingModel = require('./embeddingModel');
const BATCH = 16;
const MAX_CHARS = 2000; // the model reads ~512 tokens; the start of the text matters most
const RETRY_AFTER_MS = 10 * 60 * 1000;
// Part of every text fingerprint, so switching embedding model re-indexes everything.
const SALT = `${embeddingModel.MODEL}:1:`;
let lastError = null;
let retryAt = 0;
let vectors = null; // [{ id, vec: Float32Array }], rebuilt after the index changes
let vectorsLoadedAt = 0;
const VECTORS_MAX_AGE_MS = 60 * 1000; // so trashed and restored models catch up
const DOCS = `
SELECT m.id, m.embedding_key,
concat_ws(E'\\n', m.name, NULLIF(btrim(m.description), ''),
(SELECT string_agg(tg.name, ', ' ORDER BY tg.name) FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id WHERE mt.model_id = m.id),
NULLIF(btrim(m.notes), ''), m.source_site, d.name,
(SELECT string_agg(f.original_name, ', ' ORDER BY f.id) FROM files f WHERE f.model_id = m.id),
(SELECT string_agg(e.path, ', ' ORDER BY e.id) FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = m.id)
) AS text
FROM models m LEFT JOIN designers d ON d.id = m.designer_id
WHERE m.deleted_at IS NULL`;
const PENDING = `FROM (${DOCS}) doc WHERE doc.embedding_key IS DISTINCT FROM md5($1 || doc.text)`;
async function run() {
if (Date.now() < retryAt) return false;
const { rows } = await db.query(`SELECT doc.id, doc.text ${PENDING} ORDER BY doc.id LIMIT $2`, [SALT, BATCH]);
if (!rows.length) return false;
let vecs;
try {
vecs = await embeddingModel.embed(rows.map((r) => r.text.slice(0, MAX_CHARS)));
lastError = null;
} catch (err) {
lastError = err.message;
retryAt = Date.now() + RETRY_AFTER_MS;
console.warn('Semantic search indexing paused:', err.message);
return false;
}
for (let i = 0; i < rows.length; i++) {
await db.query('UPDATE models SET embedding = $2, embedding_key = md5($3 || $4) WHERE id = $1',
[rows[i].id, vecs[i], SALT, rows[i].text]);
}
vectors = null;
return true;
}
async function loadVectors() {
if (!vectors || Date.now() - vectorsLoadedAt > VECTORS_MAX_AGE_MS) {
const { rows } = await db.query('SELECT id, embedding FROM models WHERE embedding IS NOT NULL AND deleted_at IS NULL');
vectors = rows.map((r) => ({ id: r.id, vec: Float32Array.from(r.embedding) }));
vectorsLoadedAt = Date.now();
}
return vectors;
}
// Models ranked by how close their meaning is to the query: [{ id, score }],
// score = cosine similarity (vectors are unit length).
async function search(query, limit = 200) {
const [q] = await embeddingModel.embed([embeddingModel.QUERY_PREFIX + String(query).slice(0, 300)]);
const list = await loadVectors();
return list
.map(({ id, vec }) => {
let score = 0;
for (let i = 0; i < vec.length; i++) score += vec[i] * q[i];
return { id, score: Math.round(score * 1000) / 1000 };
})
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
async function status() {
const { rows: [counts] } = await db.query(
`SELECT (SELECT COUNT(*)::int FROM models WHERE deleted_at IS NULL AND embedding IS NOT NULL) AS indexed,
(SELECT COUNT(*)::int ${PENDING}) AS pending`, [SALT]);
return { ...counts, error: lastError };
}
// Something outside the worker changed which models are searchable (trash, restore).
function invalidate() {
vectors = null;
}
module.exports = { run, search, status, invalidate };

189
lib/filaments.js Normal file
View file

@ -0,0 +1,189 @@
// What each model asks for, and what you have on hand.
//
// A Bambu Studio / OrcaSlicer 3MF names the filaments its plates use. Reading
// that means shelling out to Python, so it is done once per model in the
// background — like thumbnails and fingerprints — and kept in models.filaments.
// With that stored, "what can I print right now" is a plain query rather than
// hundreds of subprocesses.
const db = require('./db');
const converter = require('./converter');
const BATCH = 25;
// Models that have never been looked at, paired with their first loose 3MF.
// A model whose only 3MF sits inside an archive is left for a later pass rather
// than paying an extraction for each one.
const PENDING = `
FROM models m
LEFT JOIN LATERAL (
SELECT f.file_path FROM files f
WHERE f.model_id = m.id AND f.format = '3mf'
ORDER BY f.id LIMIT 1
) f3 ON true
WHERE m.deleted_at IS NULL AND m.filaments_status IS NULL`;
// "PLA Basic" and "pla basic" are the same filament; "PLA-CF" is not.
const normalise = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
// Bambu writes #RRGGBB and #RRGGBBAA; the alpha channel is not a colour.
function rgb(hex) {
const m = /^#?([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(String(hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
// Redmean distance: closer to how an eye judges two colours than plain RGB.
// 0 is identical, black to white is about 765. Null when either is unknown.
function colourDistance(a, b) {
const x = rgb(a);
const y = rgb(b);
if (!x || !y) return null;
const rm = (x.r + y.r) / 2;
const dr = x.r - y.r;
const dg = x.g - y.g;
const db = x.b - y.b;
return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db);
}
// How close counts as the same colour. 50 keeps #000000 and #161616 apart (~66)
// while allowing genuinely near shades to stand in for each other.
const COLOUR_TOLERANCE = 50;
// Which spool fills which slot. One spool cannot fill two slots, so the best
// colour matches are assigned first. A slot whose project gives no colour is
// satisfied by any spool of the right material.
function assignSpools(filaments, spools) {
const slots = (filaments || []).map((f, i) => ({
index: i, material: normalise(f.type), colour: f.colour || null,
}));
const shelf = (spools || [])
.filter((s) => s.remaining_g === null || s.remaining_g === undefined || Number(s.remaining_g) > 0)
.map((s, i) => ({
key: s.id == null ? `i${i}` : `s${s.id}`, material: normalise(s.material),
colour: s.colour_hex || null, name: s.colour || s.material,
}));
const pairs = [];
for (const slot of slots) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
// A slot that takes any colour is filled last, from whatever is left:
// scoring it 0 would let it claim a spool an exact-match slot needs.
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: Number.MAX_SAFE_INTEGER }); continue; }
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && d <= COLOUR_TOLERANCE) pairs.push({ slot: slot.index, spool: spool.key, d });
}
}
pairs.sort((a, b) => a.d - b.d);
const filledBy = new Map();
const usedSpools = new Set();
for (const p of pairs) {
if (filledBy.has(p.slot) || usedSpools.has(p.spool)) continue;
filledBy.set(p.slot, p.spool);
usedSpools.add(p.spool);
}
return slots.map((slot) => {
const key = filledBy.get(slot.index);
const by = key ? shelf.find((s) => s.key === key) : null;
let nearest = null;
if (!by && slot.colour) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && (!nearest || d < nearest.distance)) nearest = { name: spool.name, distance: Math.round(d) };
}
}
return { ...slot, filled: Boolean(by), by: by ? by.name : null, nearest };
});
}
// The slots you can't fill from the shelf.
function shortfall(filaments, spools) {
return assignSpools(filaments, spools).filter((s) => !s.filled);
}
// True when every slot has a spool. A model we know nothing about is not
// claimed to be printable.
function isPrintable(filaments, spools) {
if (!filaments || !filaments.length) return false;
return shortfall(filaments, spools).length === 0;
}
// One pass. Returns whether it did any work, so it fits the background worker
// loop in fingerprint.js.
async function run() {
const { rows } = await db.query(
`SELECT m.id, f3.file_path ${PENDING} ORDER BY m.id LIMIT ${BATCH}`);
if (!rows.length) return false;
for (const row of rows) {
if (!row.file_path) {
await mark(row.id, 'none', null);
continue;
}
try {
const filaments = await converter.readFilaments(row.file_path);
// A plain 3MF mesh names no filaments — that is an answer, not a failure.
await mark(row.id, filaments.length ? 'done' : 'none', filaments.length ? filaments : null);
} catch (err) {
console.warn(`Could not read filaments for model ${row.id}:`, err.message);
await mark(row.id, 'error', null);
}
}
return true;
}
async function mark(modelId, status, filaments) {
await db.query('UPDATE models SET filaments = $2, filaments_status = $3 WHERE id = $1',
[modelId, filaments ? JSON.stringify(filaments) : null, status]);
}
async function pendingCount() {
const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`);
return rows[0].n;
}
// Look at every model again — after adding files, say. Slots you set by hand
// are left alone: they aren't the reader's to overwrite.
async function reread() {
await db.query("UPDATE models SET filaments_status = NULL WHERE filaments_status IS DISTINCT FROM 'edited'");
}
// Slots as sent from the page. Keeps the material, accepts only a proper hex as
// a colour, and drops anything with no material — a slot that asks for nothing
// would quietly count as satisfied.
function cleanSlots(list) {
if (!Array.isArray(list)) return null;
return list
.map((f) => ({
type: String((f && f.type) || '').trim(),
colour: /^#[0-9a-f]{6}$/i.test(String((f && f.colour) || '').trim())
? String(f.colour).trim().toUpperCase()
: null,
}))
.filter((f) => f.type);
}
// Bambu's own name and code for a spool, from the bundled filament list: the
// same material and product line, and the closest colour within a tight
// threshold — this identifies a colour rather than substituting for one.
function paletteMatch(palette, material, type, hex) {
let best = null;
for (const f of palette || []) {
if (normalise(f.material) !== normalise(material)) continue;
if (type && normalise(f.type) !== normalise(type)) continue;
for (const c of f.colours || []) {
const d = colourDistance(hex, c);
if (d !== null && d <= 12 && (!best || d < best.d)) best = { d, name: f.name, code: f.code || null, type: f.type };
}
}
return best && { name: best.name, code: best.code, type: best.type };
}
module.exports = {
run, pendingCount, reread, cleanSlots, paletteMatch,
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
};

View file

@ -11,6 +11,8 @@ const db = require('./db');
const store = require('./store'); const store = require('./store');
const archives = require('./archives'); const archives = require('./archives');
const thumbnails = require('./thumbnails'); const thumbnails = require('./thumbnails');
const autotag = require('./autotag');
const embeddings = require('./embeddings');
const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor'); const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor');
const { PYTHON, runScript } = require('./python'); const { PYTHON, runScript } = require('./python');
@ -209,8 +211,13 @@ async function fingerprintEntries() {
async function run() { async function run() {
for (;;) { for (;;) {
// Thumbnails go last so they can use what's inside newly indexed archives. // Thumbnails, tags and search come after indexing so they can use what's inside
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(), await thumbnails.generate()]; // newly indexed archives.
// require here rather than at the top: filaments.js pulls in the converter,
// and this keeps the module graph one-directional.
const filaments = require('./filaments');
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(),
await thumbnails.generate(), await autotag.run(), await embeddings.run(), await filaments.run()];
if (!worked.some(Boolean)) return; if (!worked.some(Boolean)) return;
} }
} }

View file

@ -21,6 +21,9 @@ async function solve(flaresolverrUrl, targetUrl, opts = {}) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
// FlareSolverr is told to give up at maxTimeout; if it doesn't answer
// even then, stop waiting rather than hanging the request for ever.
signal: AbortSignal.timeout((opts.maxTimeout || 60000) + 15000),
}); });
if (res.status >= 500) { lastErr = new Error(`FlareSolverr HTTP ${res.status}`); continue; } if (res.status >= 500) { lastErr = new Error(`FlareSolverr HTTP ${res.status}`); continue; }
if (!res.ok) throw new Error(`FlareSolverr HTTP ${res.status}`); if (!res.ok) throw new Error(`FlareSolverr HTTP ${res.status}`);
@ -29,7 +32,7 @@ async function solve(flaresolverrUrl, targetUrl, opts = {}) {
return data.solution.response; return data.solution.response;
} catch (err) { } catch (err) {
lastErr = err; lastErr = err;
if (!/HTTP 5\d\d|fetch failed|ECONNRESET|network/i.test(err.message)) throw err; if (!/HTTP 5\d\d|fetch failed|ECONNRESET|network|timeout|aborted/i.test(err.message)) throw err;
} }
} }
throw lastErr; throw lastErr;
@ -48,7 +51,9 @@ async function solveJson(flaresolverrUrl, targetUrl, opts = {}) {
try { try {
return JSON.parse(text); return JSON.parse(text);
} catch { } catch {
throw new Error('FlareSolverr did not return parseable JSON.'); // Keep a little of what came back: a site answering with a page instead of
// JSON is usually asking the visitor to prove they're human.
throw Object.assign(new Error('FlareSolverr did not return parseable JSON.'), { raw: text.slice(0, 300) });
} }
} }

46
lib/handoff.js Normal file
View file

@ -0,0 +1,46 @@
// Links for "Open in slicer". The slicer app downloads the file itself, outside
// the browser and without the session cookie, so it gets a short-lived link that
// grants just that one file.
const crypto = require('crypto');
const path = require('path');
const jwt = require('jsonwebtoken');
const TTL_SECONDS = 10 * 60;
const AUDIENCE = 'printhive-handoff';
const KINDS = ['file', 'entry'];
// What slicers open: meshes, projects and STEP (Bambu Studio / OrcaSlicer import STEP).
const SLICEABLE = ['3mf', 'stl', 'obj', 'step', 'stp'];
// Keyed off the session secret but never equal to it, so a hand-off link can't
// pass as a login and a login can't pass as a hand-off link.
const keyFor = (secret) => crypto.createHmac('sha256', secret).update('handoff').digest('hex');
function createToken(secret, { kind, id }, ttl = TTL_SECONDS) {
if (!KINDS.includes(kind)) throw new Error(`Unknown hand-off kind: ${kind}`);
return jwt.sign({ k: kind, id: Number(id) }, keyFor(secret), { audience: AUDIENCE, expiresIn: ttl, algorithm: 'HS256' });
}
// { kind, id }, or null for a link that's expired, altered or not a hand-off link.
function readToken(secret, token) {
try {
const p = jwt.verify(String(token), keyFor(secret), { audience: AUDIENCE, algorithms: ['HS256'] });
return KINDS.includes(p.k) && Number.isInteger(p.id) ? { kind: p.k, id: p.id } : null;
} catch {
return null;
}
}
// Slicers name the download after the link's last path segment and decode the
// link in different ways, so that segment is kept to plain ASCII.
function urlName(name) {
const ext = path.extname(String(name)).toLowerCase().replace(/[^\w.]/g, '');
const base = path.basename(String(name), path.extname(String(name)))
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '') // accents split off by NFKD: "é" -> "e"
.replace(/[^\w.-]+/g, '_')
.replace(/^[_.-]+|[_.-]+$/g, '')
.slice(0, 80);
return `${base || 'model'}${ext}`;
}
module.exports = { TTL_SECONDS, SLICEABLE, createToken, readToken, urlName };

View file

@ -52,7 +52,7 @@ async function fetchHtml(url) {
} }
// Extract Open Graph / title metadata from raw HTML. // Extract Open Graph / title metadata from raw HTML.
function parseMetadata(html, url) { function parseMetadata(html, _url) { // _url: kept for the call shape, not read
const titleTag = (html.match(/<title[^>]*>([^<]*)<\/title>/i) || [])[1]; const titleTag = (html.match(/<title[^>]*>([^<]*)<\/title>/i) || [])[1];
return { return {
title: metaContent(html, 'property', 'og:title') || (titleTag ? decodeEntities(titleTag).trim() : null), title: metaContent(html, 'property', 'og:title') || (titleTag ? decodeEntities(titleTag).trim() : null),

View file

@ -27,6 +27,19 @@ function parseInstances(html) {
return result; return result;
} }
// MakerWorld answers with an anti-bot message once an account has asked for a
// couple of files in a row. It isn't Cloudflare — FlareSolverr reports no
// challenge, and an unauthenticated call still gets a normal reply — so retrying
// doesn't clear it; only asking for fewer files does.
const CHALLENGE_RE = /not a robot|just a moment|captcha|attention required|cf[- ]challenge|verify (?:you|your|that)|verification (?:required|needed)|security check|select in this order/i;
const isChallengeText = (text) => CHALLENGE_RE.test(String(text || ''));
function isChallenge(answer) {
if (!answer || answer.url) return false;
return isChallengeText(answer.error || answer.message || answer.msg);
}
// Resolve an instance to { name, url } (a signed, short-lived CDN download URL). // Resolve an instance to { name, url } (a signed, short-lived CDN download URL).
async function resolveDownload(flaresolverrUrl, instanceId, token) { async function resolveDownload(flaresolverrUrl, instanceId, token) {
const api = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=N1`; const api = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=N1`;
@ -35,10 +48,82 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) {
}); });
} }
// Why a download-link answer came back without a link: { message, tokenRejected },
// or null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."}
function downloadProblem(answer) {
if (answer && answer.url) return null;
const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim();
if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) {
return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true };
}
if (isChallenge(answer)) {
return {
message: 'MakerWorld’s anti-bot limit blocked the download — it only allows a couple in a row.',
tokenRejected: false,
challenged: true,
};
}
return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false };
}
// The collection id in a MakerWorld collection link, or null.
function collectionIdFromUrl(url) {
const m = String(url || '').match(/makerworld\.com\/(?:[a-z-]{2,5}\/)?collections\/(\d+)/i);
return m ? m[1] : null;
}
// Where a collection page loads the rest of its models from (its own "load more"
// call). A stable `seed` keeps the pages from overlapping.
const collectionDesignsUrl = (collectionId, { offset = 0, limit = 64, seed } = {}) =>
`https://makerworld.com/api/v1/design-service/favorites/${collectionId}/designs?offset=${offset}&limit=${limit}`
+ (seed === undefined || seed === null ? '' : `&seed=${seed}`);
// That call's answer -> { designs, total, seed }.
function parseCollectionDesigns(json) {
const hits = (json && json.hits) || [];
return {
designs: hits
.filter((d) => d && (typeof d.id === 'number' || typeof d.id === 'string'))
.map((d) => ({ id: String(d.id), slug: d.slug || '', title: d.title || '' })),
total: Number(json && json.total) || 0,
seed: json ? json.seed : undefined,
};
}
const designUrl = (design) => `https://makerworld.com/en/models/${design.id}${design.slug ? `-${design.slug}` : ''}`;
// A collection page -> { id, title, total, designs: [{ id, slug, title }] }, or null
// when the page holds no collection (a private one read without a signed-in token).
// `total` is how many models the collection holds; the page may list fewer.
function parseCollection(html) {
const m = String(html || '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return null;
let props;
try { props = JSON.parse(m[1]).props.pageProps; } catch { return null; }
// A collection you can't see (private, read without a signed-in token) comes back
// as a placeholder: id -1, no title, no models.
const id = Number(props && props.favorite && props.favorite.id);
if (!Number.isFinite(id) || id <= 0) return null;
const hits = (props.favoriteDesigns && props.favoriteDesigns.hits) || [];
const designs = hits
.filter((d) => d && (typeof d.id === 'number' || typeof d.id === 'string'))
.map((d) => ({ id: String(d.id), slug: d.slug || '', title: d.title || '' }));
const total = Number((props.favoriteDesigns && props.favoriteDesigns.total) ?? props.favorite.designCnt);
return {
id: String(id),
title: props.favorite.title || '',
total: Number.isFinite(total) ? total : designs.length,
designs,
};
}
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License"). // Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
function parseLicense(html) { function parseLicense(html) {
const m = html.match(/"license"\s*:\s*"([^"]+)"/); const m = html.match(/"license"\s*:\s*"([^"]+)"/);
return m ? m[1] : null; return m ? m[1] : null;
} }
module.exports = { parseInstances, resolveDownload, parseLicense }; module.exports = {
parseInstances, resolveDownload, downloadProblem, isChallenge, isChallengeText, parseLicense,
parseCollection, parseCollectionDesigns, collectionIdFromUrl, collectionDesignsUrl, designUrl,
};

170
lib/printer.js Normal file
View file

@ -0,0 +1,170 @@
// A read-only connection to a Bambu Lab printer on your network.
//
// The printer runs an MQTT broker on port 8883 — TLS with a self-signed
// certificate, username "bblp", and the access code from its screen as the
// password. Printhive only subscribes to the printer's reports and asks once for
// a full one ("pushall"); it never sends anything that changes what the printer
// is doing.
const mqtt = require('mqtt');
const PORT = 8883;
// An id the printer sends as all zeros means no RFID tag was read.
const realId = (v) => (v && !/^0+$/.test(String(v)) ? String(v) : null);
// The product line within a material: "PLA Basic" with material "PLA" → "Basic".
function productLine(brand, material) {
const b = String(brand || '').trim();
const m = String(material || '').trim();
if (!b) return null;
const line = m && b.toUpperCase().startsWith(`${m.toUpperCase()} `) ? b.slice(m.length) : b;
return line.trim() || null;
}
// One tray, as the printer describes it. `remain` is a percentage; the printer
// sends -1 when it can't tell. An AMS lite only knows full or empty, so its
// figure isn't a measurement — callers decide whether to trust it.
function describeTray(tray, unit = null) {
const loaded = Boolean(tray && tray.tray_type);
return {
unit,
slot: tray && tray.id != null ? Number(tray.id) : null,
empty: !loaded,
material: loaded ? tray.tray_type : null,
colour: loaded && tray.tray_color ? `#${String(tray.tray_color).slice(0, 6).toUpperCase()}` : null,
profile: loaded ? tray.tray_info_idx || null : null, // Bambu's filament profile, e.g. GFA00
brand: loaded ? tray.tray_sub_brands || null : null,
remain: loaded && Number(tray.remain) >= 0 ? Number(tray.remain) : null,
// A Bambu spool's RFID tag: tray_uuid names the spool, tag_uid the tag.
uuid: loaded ? realId(tray.tray_uuid) : null,
tagUid: loaded ? realId(tray.tag_uid) : null,
idName: loaded ? tray.tray_id_name || null : null, // e.g. "A00-W1", on firmware that sends it
weight: loaded && Number(tray.tray_weight) > 0 ? Number(tray.tray_weight) : null, // grams, when new
type: loaded ? productLine(tray.tray_sub_brands, tray.tray_type) : null,
};
}
// Every tray in every AMS unit, in order.
function describeAms(units) {
const trays = [];
for (const unit of Array.isArray(units) ? units : []) {
for (const tray of unit.tray || []) trays.push(describeTray(tray, Number(unit.id)));
}
return trays;
}
// Speed settings as the printer numbers them.
const SPEEDS = { 1: 'Silent', 2: 'Standard', 3: 'Sport', 4: 'Ludicrous' };
const num = (v) => (v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? undefined : Number(v));
// The status fields in one report. Printers often send only what changed, so
// anything a report leaves out is left out here too, for the caller to merge.
function readStatus(p) {
const out = {};
const set = (key, value) => { if (value !== undefined) out[key] = value; };
set('state', p.gcode_state || undefined);
set('progress', num(p.mc_percent));
set('remainingMin', num(p.mc_remaining_time));
set('file', p.subtask_name || p.gcode_file || undefined);
set('layer', num(p.layer_num));
set('totalLayers', num(p.total_layer_num));
set('nozzle', num(p.nozzle_temper));
set('nozzleTarget', num(p.nozzle_target_temper));
set('bed', num(p.bed_temper));
set('bedTarget', num(p.bed_target_temper));
set('chamber', num(p.chamber_temper));
if (p.spd_lvl !== undefined) set('speed', SPEEDS[Number(p.spd_lvl)] || null);
if (p.print_error !== undefined) {
const code = Number(p.print_error) || 0;
set('printError', code ? code.toString(16).toUpperCase().padStart(8, '0') : null);
}
if (Array.isArray(p.hms)) {
// Health messages: a module/severity word and a code, shown as Bambu writes them.
set('hms', p.hms.map((h) => {
const hex = (n) => (Number(n) >>> 0).toString(16).toUpperCase().padStart(8, '0');
const a = hex(h.attr);
const c = hex(h.code);
return `${a.slice(0, 4)}_${a.slice(4)}_${c.slice(0, 4)}_${c.slice(4)}`;
}));
}
return out;
}
// The messages the MQTT library gives are terse; say what they mean.
function friendlyError(err) {
const m = String((err && err.message) || err || '');
if (/not authori[sz]ed|bad user ?name or password/i.test(m)) return 'The printer refused the access code.';
if (/ECONNREFUSED/.test(m)) return 'Nothing answered on port 8883 at that address.';
if (/ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|timed? ?out/i.test(m)) return 'Couldn’t reach the printer from the server.';
return m.slice(0, 200) || 'The connection failed.';
}
// Connects, asks for a full report, and gathers what arrives for a few seconds.
// Resolves with what was seen — never with the access code.
// With `debug`, also returns one loaded tray exactly as the printer sent it, to
// see which fields a given printer and firmware actually provide.
function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) {
return new Promise((resolve) => {
const started = Date.now();
const seen = { connected: false, reports: 0, printer: null, ams: null, external: null, error: null, raw: null };
let done = false;
let timer = null;
const client = mqtt.connect(`mqtts://${host}:${PORT}`, {
username: 'bblp',
password: accessCode,
rejectUnauthorized: false, // the printer's certificate is self-signed
connectTimeout: 6000,
reconnectPeriod: 0, // one attempt: this is a test, not a session
clientId: `printhive-${Math.random().toString(16).slice(2, 10)}`,
});
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
client.end(true);
resolve({ ...seen, ms: Date.now() - started });
};
timer = setTimeout(finish, waitMs);
client.on('connect', () => {
seen.connected = true;
client.subscribe(`device/${serial}/report`, (err) => {
if (err) { seen.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
// The printer only sends everything when asked.
client.publish(`device/${serial}/request`,
JSON.stringify({ pushing: { sequence_id: '0', command: 'pushall' } }));
});
});
client.on('message', (_topic, payload) => {
seen.reports += 1;
let msg;
try { msg = JSON.parse(payload.toString()); } catch { return; }
const p = msg && msg.print;
if (!p) return;
if (p.gcode_state) seen.printer = { ...(seen.printer || {}), state: p.gcode_state };
if (p.mc_percent != null) seen.printer = { ...(seen.printer || {}), progress: Number(p.mc_percent) };
if (p.ams && Array.isArray(p.ams.ams)) {
seen.ams = describeAms(p.ams.ams);
if (debug) {
const unit = p.ams.ams[0] || {};
seen.raw = {
amsKeys: Object.keys(p.ams),
unitKeys: Object.keys(unit),
unitInfo: { info: unit.info, humidity: unit.humidity, temp: unit.temp },
trayNow: p.ams.tray_now, trayTar: p.ams.tray_tar,
tray: (unit.tray || []).find((t) => t && t.tray_type) || null,
};
}
}
if (p.vt_tray) seen.external = describeTray(p.vt_tray, null);
});
client.on('error', (err) => {
seen.error = friendlyError(err);
finish();
});
client.on('close', () => { if (!seen.connected) finish(); });
});
}
module.exports = { probe, describeAms, describeTray, friendlyError, productLine, readStatus, PORT };

159
lib/printerCamera.js Normal file
View file

@ -0,0 +1,159 @@
// The chamber camera on a P1 or A1 series printer.
//
// The printer serves still frames on port 6000 over TLS: an 80-byte hello with
// the same "bblp" username and access code as the status connection, then a
// stream of JPEGs, each behind a 16-byte header giving its size. Roughly one
// frame a second — stills, not video. Read-only, like everything else here.
//
// X1 printers don't do this; they serve RTSP on port 322, which a browser
// can't show without transcoding.
const tls = require('tls');
const PORT = 6000;
const HEADER = 16;
const MAX_FRAME = 12 * 1024 * 1024; // a frame is well under this; a bigger one means we've lost our place
function hello(accessCode) {
const buf = Buffer.alloc(80);
buf.writeUInt32LE(0x40, 0); // payload length
buf.writeUInt32LE(0x3000, 4); // protocol marker
buf.write('bblp', 16, 32, 'ascii');
buf.write(String(accessCode).slice(0, 31), 48, 32, 'ascii');
return buf;
}
// The picture's size, from the JPEG's own frame header — a cheap sanity check
// that what arrived really is an image.
function jpegSize(buf) {
let i = 2;
while (i + 9 <= buf.length) {
if (buf[i] !== 0xff) { i += 1; continue; }
const marker = buf[i + 1];
// SOF0…SOF3, SOF5…SOF7, SOF9…SOF11, SOF13…SOF15 carry the dimensions.
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
}
i += 2 + buf.readUInt16BE(i + 2);
}
return null;
}
// Connects and calls onFrame for each JPEG until stop() is called.
// onError is called once, with a plain-language reason.
function watch({ host, accessCode, onFrame, onError = () => {}, connectTimeoutMs = 8000 }) {
let closed = false;
let pending = Buffer.alloc(0);
let expecting = null; // size of the frame being gathered
const socket = tls.connect({
host,
port: PORT,
rejectUnauthorized: false, // the printer's certificate is self-signed
timeout: connectTimeoutMs,
});
const fail = (message) => {
if (closed) return;
closed = true;
socket.destroy();
onError(message);
};
socket.on('secureConnect', () => {
socket.setTimeout(0);
socket.write(hello(accessCode));
});
socket.on('timeout', () => fail('The printer didn’t answer on its camera port.'));
socket.on('error', (err) => fail(
/ECONNREFUSED/.test(err.message)
? 'The printer refused the camera connection — “LAN Mode Liveview” may be off.'
: err.message));
socket.on('close', () => fail('The printer closed the camera connection — the access code may be wrong, or “LAN Mode Liveview” is off.'));
socket.on('data', (chunk) => {
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
for (;;) {
if (expecting === null) {
if (pending.length < HEADER) return;
expecting = pending.readUInt32LE(0);
pending = pending.subarray(HEADER);
if (!expecting || expecting > MAX_FRAME) { fail('The printer sent something Printhive couldn’t read as a picture.'); return; }
}
if (pending.length < expecting) return;
const frame = pending.subarray(0, expecting);
pending = pending.subarray(expecting);
expecting = null;
// A frame always starts with the JPEG marker; anything else means trouble.
if (frame[0] !== 0xff || frame[1] !== 0xd8) { fail('The printer sent something Printhive couldn’t read as a picture.'); return; }
onFrame(frame);
}
});
return () => {
if (closed) return;
closed = true;
socket.destroy();
};
}
// One connection per printer, however many people are watching, closed as soon
// as the last of them looks away. Frames aren't stored beyond the latest one,
// which new viewers get straight away instead of waiting for the next.
const feeds = new Map();
function subscribe(id, { host, accessCode }, onFrame, onError = () => {}) {
let feed = feeds.get(id);
if (!feed) {
feed = { viewers: new Set(), latest: null, stop: () => {} };
feeds.set(id, feed);
feed.stop = watch({
host,
accessCode,
onFrame: (frame) => {
feed.latest = frame;
for (const v of feed.viewers) v.onFrame(frame);
},
onError: (error) => {
feeds.delete(id);
for (const v of feed.viewers) v.onError(error);
feed.viewers.clear();
},
});
}
const viewer = { onFrame, onError };
feed.viewers.add(viewer);
if (feed.latest) onFrame(feed.latest);
return () => {
feed.viewers.delete(viewer);
if (!feed.viewers.size) {
feed.stop();
feeds.delete(id);
}
};
}
// Connects, waits for one frame, and says what came back. Used to check a
// printer's camera without opening a viewer.
function testCamera({ host, accessCode, waitMs = 10000 }) {
return new Promise((resolve) => {
const started = Date.now();
let stop = () => {};
let done = false;
const finish = (result) => {
if (done) return;
done = true;
clearTimeout(timer);
stop();
resolve({ ...result, ms: Date.now() - started });
};
const timer = setTimeout(() => finish({ ok: false, error: 'No picture arrived within ten seconds.' }), waitMs);
stop = watch({
host,
accessCode,
onFrame: (frame) => finish({ ok: true, bytes: frame.length, ...(jpegSize(frame) || {}) }),
onError: (error) => finish({ ok: false, error }),
});
});
}
module.exports = { watch, subscribe, testCamera, jpegSize, hello, PORT };

235
lib/printerLive.js Normal file
View file

@ -0,0 +1,235 @@
// Live, read-only connections to your printers.
//
// One connection per printer stays open, so status arrives as the printer sends
// it (every second or two while printing) instead of on a timer. The printer is
// only ever asked for a full report — never sent anything that changes what it's
// doing. Access codes are held in memory for the connection and never leave
// this module.
const mqtt = require('mqtt');
const { describeAms, describeTray, friendlyError, readStatus, PORT } = require('./printer');
const RECONNECT_MS = 15 * 1000;
const FULL_REPORT_MS = 10 * 60 * 1000; // a fresh full report now and then, in case a change was missed
const TRAYS_SETTLE_MS = 5 * 1000; // let a spool swap finish reporting before syncing it
const connections = new Map(); // printer id → connection
let onTrays = async () => null;
let connect = (url, options) => mqtt.connect(url, options); // replaceable in tests
// What's loaded, reduced to what decides whether spools need syncing.
const traysKey = (trays) => JSON.stringify((trays || [])
.map((t) => [t.unit, t.slot, t.empty, t.material, t.colour, t.uuid]));
function allTrays(c) {
return [...(c.ams || []), ...(c.external ? [c.external] : [])];
}
// Which tray is feeding the toolhead. The printer sends `tray_now` as a number:
// 255 for none, 254 for the external spool, otherwise unit × 4 + slot — where
// "unit" is the AMS's id on most printers, or its position among the units.
function activeTray(c) {
const n = c.trayNow;
if (n == null || Number.isNaN(n) || n === 255) return null;
if (n === 254) return { external: true };
const ids = c.unitIds || [];
const byId = Math.floor(n / 4);
const unit = ids.includes(byId) ? byId : ids[byId];
return unit === undefined ? null : { unit, slot: n % 4 };
}
function requestFullReport(c) {
if (!c.client.connected) return;
c.client.publish(`device/${c.serial}/request`,
JSON.stringify({ pushing: { sequence_id: String(Date.now()), command: 'pushall' } }));
}
// Syncs spools with what's loaded now, and hands back what that did.
async function flushTrays(c) {
clearTimeout(c.traysTimer);
c.traysTimer = null;
c.pendingKey = null;
const trays = allTrays(c);
if (!c.ams && !c.external) return null;
c.syncedKey = traysKey(trays);
try {
c.lastSync = await onTrays(c.id, trays);
return c.lastSync;
} catch (err) {
console.warn(`Printer ${c.id} spool sync failed:`, err.message);
return null;
}
}
function handleMessage(c, payload) {
let msg;
try { msg = JSON.parse(payload.toString()); } catch { return; }
const p = msg && msg.print;
if (!p) return;
c.reports += 1;
c.updatedAt = new Date();
Object.assign(c.status, readStatus(p));
// AMS details come whole when they change; a report without them changes nothing.
const units = p.ams && p.ams.ams;
if (Array.isArray(units) && units.every((u) => Array.isArray(u.tray))) {
c.ams = describeAms(units);
c.unitIds = units.map((u) => Number(u.id));
}
if (p.ams && p.ams.tray_now !== undefined) c.trayNow = Number(p.ams.tray_now);
if (p.vt_tray) c.external = describeTray(p.vt_tray, null);
// A printer mid-print reports every second or so, so only a change in what's
// loaded restarts the wait — otherwise the sync would never come round.
const key = (c.ams || c.external) ? traysKey(allTrays(c)) : null;
if (key && key !== c.syncedKey && key !== c.pendingKey) {
c.pendingKey = key;
clearTimeout(c.traysTimer);
c.traysTimer = setTimeout(() => flushTrays(c), TRAYS_SETTLE_MS);
}
// Someone waiting for a full report is answered by one with the trays in it.
if (Array.isArray(units) || p.vt_tray) for (const resolve of c.waiters.splice(0)) resolve();
}
function open(p) {
const c = {
id: p.id,
serial: p.serial,
key: [p.host, p.serial, p.access_code].join('\n'),
connected: false,
error: null,
reports: 0,
updatedAt: null,
connectedAt: null,
status: {},
ams: null,
external: null,
syncedKey: null,
pendingKey: null,
trayNow: null,
unitIds: [],
lastSync: null,
traysTimer: null,
waiters: [],
};
c.client = connect(`mqtts://${p.host}:${PORT}`, {
username: 'bblp',
password: p.access_code,
rejectUnauthorized: false, // the printer's certificate is self-signed
connectTimeout: 10 * 1000,
reconnectPeriod: RECONNECT_MS,
keepalive: 30,
clientId: `printhive-${p.id}-${Math.random().toString(16).slice(2, 8)}`,
});
c.client.on('connect', () => {
c.connected = true;
c.error = null;
c.connectedAt = new Date();
c.client.subscribe(`device/${c.serial}/report`, (err) => {
if (err) { c.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
requestFullReport(c);
});
});
c.client.on('message', (_topic, payload) => handleMessage(c, payload));
c.client.on('error', (err) => { c.error = friendlyError(err); });
c.client.on('close', () => { c.connected = false; });
c.client.on('offline', () => { c.connected = false; });
c.fullReportTimer = setInterval(() => requestFullReport(c), FULL_REPORT_MS);
c.fullReportTimer.unref();
return c;
}
function close(c) {
clearInterval(c.fullReportTimer);
clearTimeout(c.traysTimer);
for (const resolve of c.waiters.splice(0)) resolve();
c.client.end(true);
}
// Brings the connections in line with the printers you have: new ones opened,
// removed ones closed, and any whose address, serial or code changed reopened.
function reconcile(printerRows) {
const wanted = new Map();
for (const p of printerRows) {
if (p.host && p.serial && p.access_code) wanted.set(p.id, p);
}
for (const [id, c] of connections) {
const p = wanted.get(id);
if (!p || [p.host, p.serial, p.access_code].join('\n') !== c.key) {
close(c);
connections.delete(id);
}
}
for (const [id, p] of wanted) {
if (!connections.has(id)) connections.set(id, open(p));
}
}
// What one printer is doing, as last reported. Nothing here identifies a spool
// or carries a credential.
function snapshot(id) {
const c = connections.get(id);
if (!c) return { live: false };
const loaded = allTrays(c).map((t) => ({
unit: t.unit, slot: t.slot, empty: t.empty, material: t.material, colour: t.colour, tagged: Boolean(t.uuid),
}));
return {
live: true,
connected: c.connected,
error: c.connected ? null : c.error,
updatedAt: c.updatedAt,
status: { ...c.status, active: activeTray(c) },
loaded: c.ams || c.external ? loaded : null,
};
}
function snapshots() {
const out = {};
for (const id of connections.keys()) out[id] = snapshot(id);
return out;
}
// Asks for a full report now, waits for it, and syncs spools from it — what
// "Test & sync" does. Uses the open connection rather than making another.
async function refresh(id, waitMs = 8000) {
const c = connections.get(id);
if (!c) return null;
const started = Date.now();
const before = c.reports;
if (!c.connected && !c.client.reconnecting) c.client.reconnect();
await new Promise((resolve) => {
const timer = setTimeout(resolve, waitMs);
const ask = () => {
if (c.connected) { requestFullReport(c); return; }
if (Date.now() - started < waitMs) setTimeout(ask, 250);
};
c.waiters.push(() => { clearTimeout(timer); setTimeout(resolve, 1000); }); // let the rest of the report arrive
ask();
});
const sync = c.reports > before ? await flushTrays(c) : null;
return {
connected: c.connected,
reports: c.reports - before,
printer: { state: c.status.state, progress: c.status.progress },
ams: c.ams,
external: c.external,
error: c.connected ? null : (c.error || 'Couldn’t reach the printer.'),
sync,
ms: Date.now() - started,
};
}
function setTraysHandler(fn) {
onTrays = fn;
}
function stopAll() {
for (const c of connections.values()) close(c);
connections.clear();
}
// For tests: connect with a stand-in instead of the network.
function setConnector(fn) {
connect = fn;
}
module.exports = { reconcile, snapshot, snapshots, refresh, setTraysHandler, stopAll, setConnector };

105
lib/printers.js Normal file
View file

@ -0,0 +1,105 @@
// The printers you've added.
//
// Access codes are stored here but never handed back to the browser: every
// read returns `codeSet` instead, and saving with a blank code keeps the one
// already stored.
const db = require('./db');
// Which AMS types can say how much filament is left. An AMS lite only knows
// full or empty, so its reading is never used as a weight.
const AMS_TYPES = { none: false, ams_lite: false, ams: true, ams_2_pro: true, ams_ht: true };
const clean = (value, max) => {
const s = String(value ?? '').trim();
return s ? s.slice(0, max) : null;
};
function publicPrinter(row) {
if (!row) return null;
const { access_code: accessCode, ...rest } = row;
return { ...rest, codeSet: Boolean(accessCode) };
}
function fields(data) {
return {
nickname: clean(data.nickname, 60),
model: clean(data.model, 40),
host: clean(data.host, 255),
serial: clean(data.serial, 40),
ams_type: Object.prototype.hasOwnProperty.call(AMS_TYPES, data.ams_type) ? data.ams_type : null,
};
}
async function list() {
const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
return rows.map(publicPrinter);
}
// Server-side only: includes the access code, for connecting.
async function listWithCodes() {
const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
return rows;
}
async function get(id, { withCode = false } = {}) {
const { rows: [row] } = await db.query('SELECT * FROM printers WHERE id = $1', [id]);
if (!row) return null;
return withCode ? row : publicPrinter(row);
}
async function create(data) {
const f = fields(data);
const { rows: [row] } = await db.query(
`INSERT INTO printers (nickname, model, host, serial, access_code, ams_type)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[f.nickname, f.model, f.host, f.serial, clean(data.access_code, 64), f.ams_type]);
return publicPrinter(row);
}
// Only the fields sent are changed; a blank access code keeps the stored one.
async function update(id, data) {
const f = fields(data);
const has = (key) => Object.prototype.hasOwnProperty.call(data, key);
const { rows: [row] } = await db.query(
`UPDATE printers SET
nickname = CASE WHEN $2::boolean THEN $3 ELSE nickname END,
model = CASE WHEN $4::boolean THEN $5 ELSE model END,
host = CASE WHEN $6::boolean THEN $7 ELSE host END,
serial = CASE WHEN $8::boolean THEN $9 ELSE serial END,
ams_type = CASE WHEN $10::boolean THEN $11 ELSE ams_type END,
access_code = COALESCE($12, access_code),
date_modified = now()
WHERE id = $1 RETURNING *`,
[id, has('nickname'), f.nickname, has('model'), f.model, has('host'), f.host,
has('serial'), f.serial, has('ams_type'), f.ams_type, clean(data.access_code, 64)]);
return publicPrinter(row);
}
// Removing a printer also marks its spools as no longer loaded anywhere.
async function remove(id) {
const { rows: [row] } = await db.query('DELETE FROM printers WHERE id = $1 RETURNING serial', [id]);
if (!row) return false;
if (row.serial) {
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE printer_serial = $1', [row.serial]);
}
return true;
}
// Keeps what a printer reported loaded — enough to draw its card, nothing that
// identifies a spool.
async function recordLoaded(id, trays) {
const loaded = (trays || []).map((t) => ({
unit: t.unit ?? null,
slot: t.slot ?? null,
empty: Boolean(t.empty),
material: t.material || null,
colour: t.colour || null,
tagged: Boolean(t.uuid),
}));
await db.query('UPDATE printers SET loaded = $2::jsonb, loaded_at = now() WHERE id = $1',
[id, JSON.stringify(loaded)]);
}
const measures = (amsType) => Boolean(AMS_TYPES[amsType]);
module.exports = { AMS_TYPES, list, listWithCodes, get, create, update, remove, recordLoaded, measures, publicPrinter };

138
lib/spoolSync.js Normal file
View file

@ -0,0 +1,138 @@
// Keeps the Filament page in step with what a printer has loaded.
//
// planSync() works out, without touching the database, what a printer's report
// means for your spools; applySync() carries that out. Only spools with an RFID
// tag are handled automatically: an untagged spool looks identical to any other
// of its colour, so there's no way to tell it from one you already have.
const db = require('./db');
const { colourDistance, normalise, paletteMatch } = require('./filaments');
// How close a hand-added spool's colour must be for it to adopt a printer's tag.
const ADOPT_TOLERANCE = 12;
// Where a tray is, as stored on a spool: "<printer serial>/<unit>/<slot>", with
// "ext" as the unit for the external spool holder.
const slotKey = (printerSerial, tray) =>
`${printerSerial}/${tray.unit == null ? 'ext' : tray.unit}/${tray.slot == null ? 0 : tray.slot}`;
function planSync(existing, trays, printerSerial, palette, { measures = false } = {}) {
const actions = [];
// Grams left, only when this printer's AMS actually measures it. An AMS lite
// reports full or empty, which is not a weight.
const gramsLeft = (tray) => (measures && tray.remain != null && tray.weight
? Math.round((tray.weight * tray.remain) / 100)
: null);
const untracked = [];
const byUuid = new Map(existing.filter((s) => s.rfid_uuid).map((s) => [s.rfid_uuid, s]));
const adopted = new Set(); // hand-added spools already matched in this report
const seen = new Set(); // existing spools this report accounts for
for (const tray of trays || []) {
if (!tray || tray.empty) continue;
const slot = slotKey(printerSerial, tray);
if (!tray.uuid) {
untracked.push({ slot, material: tray.material, colour: tray.colour });
continue;
}
const known = byUuid.get(tray.uuid);
if (known) {
seen.add(known.id);
actions.push({ op: 'update', id: known.id, slot, printerSerial, remaining: gramsLeft(tray) });
continue;
}
const named = paletteMatch(palette, tray.material, tray.type, tray.colour);
// A spool you added by hand before the printer reported it: take it over
// rather than adding a second copy.
const adopt = existing.find((s) => {
if (s.rfid_uuid || adopted.has(s.id)) return false;
if (normalise(s.material) !== normalise(tray.material)) return false;
const d = colourDistance(s.colour_hex, tray.colour);
return d !== null && d <= ADOPT_TOLERANCE;
});
if (adopt) {
adopted.add(adopt.id);
seen.add(adopt.id);
actions.push({
op: 'adopt', id: adopt.id, slot, printerSerial,
uuid: tray.uuid, tagUid: tray.tagUid, productCode: tray.idName,
code: named ? named.code : null, totalG: tray.weight, remaining: gramsLeft(tray),
});
continue;
}
actions.push({
op: 'create', slot, printerSerial,
spool: {
material: tray.material,
colour: named ? named.name : (tray.idName || tray.colour || tray.material),
colour_hex: tray.colour,
brand: 'Bambu Lab',
code: named ? named.code : null,
total_g: tray.weight,
remaining_g: gramsLeft(tray), // blank unless this printer's AMS measures it
rfid_uuid: tray.uuid,
tag_uid: tray.tagUid,
product_code: tray.idName,
},
});
}
// Anything this printer used to hold that it no longer reports has come out.
for (const s of existing) {
if (s.printer_serial === printerSerial && s.ams_slot && !seen.has(s.id)) {
actions.push({ op: 'unload', id: s.id });
}
}
return { actions, untracked };
}
async function applySync({ actions }) {
const summary = { created: 0, adopted: 0, updated: 0, unloaded: 0 };
for (const a of actions) {
if (a.op === 'create') {
const s = a.spool;
const { rowCount } = await db.query(
`INSERT INTO filament_spools (material, colour, colour_hex, brand, code, total_g, remaining_g,
rfid_uuid, tag_uid, product_code, ams_slot, printer_serial, last_seen_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now())
ON CONFLICT (rfid_uuid) WHERE rfid_uuid IS NOT NULL DO NOTHING`,
[s.material, s.colour, s.colour_hex, s.brand, s.code, s.total_g, s.remaining_g,
s.rfid_uuid, s.tag_uid, s.product_code, a.slot, a.printerSerial]);
summary.created += rowCount;
} else if (a.op === 'adopt') {
const { rowCount } = await db.query(
`UPDATE filament_spools SET rfid_uuid = $2, tag_uid = $3, product_code = $4,
code = COALESCE(code, $5), total_g = COALESCE(total_g, $6),
ams_slot = $7, printer_serial = $8, remaining_g = COALESCE($9, remaining_g),
last_seen_at = now(), date_modified = now()
WHERE id = $1 AND rfid_uuid IS NULL`,
[a.id, a.uuid, a.tagUid, a.productCode, a.code, a.totalG, a.slot, a.printerSerial, a.remaining ?? null]);
summary.adopted += rowCount;
} else if (a.op === 'update') {
// A measured weight replaces the stored one; no reading leaves it alone.
await db.query(
`UPDATE filament_spools SET ams_slot = $2, printer_serial = $3, last_seen_at = now(),
remaining_g = COALESCE($4, remaining_g)
WHERE id = $1`,
[a.id, a.slot, a.printerSerial, a.remaining ?? null]);
summary.updated += 1;
} else if (a.op === 'unload') {
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE id = $1', [a.id]);
summary.unloaded += 1;
}
}
return summary;
}
// Reads the shelf, plans against one printer's trays, and applies it.
async function syncPrinter(printerSerial, trays, palette, options = {}) {
const { rows: existing } = await db.query('SELECT * FROM filament_spools');
const plan = planSync(existing, trays, printerSerial, palette, options);
const summary = await applySync(plan);
return { ...summary, untracked: plan.untracked };
}
module.exports = { planSync, syncPrinter, slotKey };

View file

@ -5,15 +5,16 @@ const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor');
const MODEL_SELECT = ` const MODEL_SELECT = `
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id, SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified, m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
m.notes, m.deleted_at, m.notes, m.deleted_at, m.ai_tagged_at, m.original_name, m.filaments, m.filaments_status,
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url, d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
COALESCE(t.tags, '[]'::json) AS tags, COALESCE(t.tags, '[]'::json) AS tags,
COALESCE(t.tag_sources, '{}'::json) AS tag_sources,
COALESCE(p.projects, '[]'::json) AS projects, COALESCE(p.projects, '[]'::json) AS projects,
COALESCE(f.files, '[]'::json) AS files COALESCE(f.files, '[]'::json) AS files
FROM models m FROM models m
LEFT JOIN designers d ON d.id = m.designer_id LEFT JOIN designers d ON d.id = m.designer_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT json_agg(tg.name ORDER BY tg.name) AS tags SELECT json_agg(tg.name ORDER BY tg.name) AS tags, json_object_agg(tg.name, mt.source) AS tag_sources
FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id
WHERE mt.model_id = m.id WHERE mt.model_id = m.id
) t ON true ) t ON true
@ -102,6 +103,11 @@ async function updateModel(id, updates) {
for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) { for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) {
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); } if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
} }
// A name you give it yourself replaces an AI rename, so there's nothing left to undo.
if (updates.name !== undefined) {
fields.push(`original_name = CASE WHEN name IS DISTINCT FROM $${i++} THEN NULL ELSE original_name END`);
values.push(updates.name);
}
if (updates.print_settings !== undefined) { if (updates.print_settings !== undefined) {
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings); fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
} }
@ -295,18 +301,182 @@ async function insertFile(client, modelId, file) {
} }
// ---------------------------------------------------------------- tags // ---------------------------------------------------------------- tags
async function setTags(client, modelId, tagNames) { // Always tag in the same order. Two transactions touching the same tags in
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]); // opposite orders each hold a row the other wants, which Postgres resolves by
for (const raw of tagNames) { // killing one of them ("deadlock detected").
const name = String(raw).trim(); const tagOrder = (names) => [...new Set((names || []).map((n) => String(n).trim()).filter(Boolean))].sort();
if (!name) continue;
// The tag's id, creating it if it's new. Deliberately not ON CONFLICT DO UPDATE:
// that rewrites the row and so write-locks it for the rest of the transaction,
// even though the name never changes — which is what made tagging deadlock.
async function tagId(client, name) {
for (let attempt = 0; attempt < 3; attempt++) {
const { rows: found } = await client.query('SELECT id FROM tags WHERE name = $1', [name]);
if (found.length) return found[0].id;
const { rows } = await client.query( const { rows } = await client.query(
`INSERT INTO tags (name) VALUES ($1) 'INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id', [name]);
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]); if (rows.length) return rows[0].id;
await client.query( // Someone else is inserting this very tag and hasn't committed yet.
'INSERT INTO model_tags (model_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', await new Promise((r) => setTimeout(r, 20 * (attempt + 1)));
[modelId, rows[0].id]);
} }
throw new Error(`Could not get an id for the tag “${name}”`);
}
// Sets a model's tags from an edit. A tag that stays keeps where it came from
// (rule / ai); new ones are yours.
async function setTags(client, modelId, tagNames) {
const { rows: before } = await client.query('SELECT tag_id, source FROM model_tags WHERE model_id = $1', [modelId]);
const sourceOf = new Map(before.map((r) => [r.tag_id, r.source]));
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
for (const name of tagOrder(tagNames)) {
const id = await tagId(client, name);
await client.query(
'INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
[modelId, id, sourceOf.get(id) || 'user']);
}
}
// Automatic tagging: makes the model's rule tags exactly `tagNames`. A tag the
// model already has from you or the AI is left as it is.
async function replaceRuleTags(modelId, tagNames) {
await db.transaction(async (client) => {
const ids = [];
for (const name of tagOrder(tagNames)) ids.push(await tagId(client, name));
await client.query(
`DELETE FROM model_tags WHERE model_id = $1 AND source = 'rule' AND NOT (tag_id = ANY($2::int[]))`, [modelId, ids]);
for (const id of ids) {
await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'rule') ON CONFLICT DO NOTHING`,
[modelId, id]);
}
});
}
async function removeTagsFrom(source) {
const { rowCount } = await db.query('DELETE FROM model_tags WHERE source = $1', [source]);
return rowCount;
}
// AI tagging: adds the suggested tags (never removes any), fills in the
// description if it's empty (or replaces it when asked to), and renames the model
// when given a name, keeping the name it had before AI first renamed it.
// markTagged records that the model has had AI tags and a description.
async function applyAiResult(modelId, { tags, description, overwriteDescription, name = null, markTagged = true }) {
await db.transaction(async (client) => {
for (const tagName of tagOrder(tags)) { // not `name`: that's this function's rename parameter
const id = await tagId(client, tagName);
await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING`,
[modelId, id]);
}
await client.query(
`UPDATE models SET ai_tagged_at = CASE WHEN $5::boolean THEN now() ELSE ai_tagged_at END,
description = CASE WHEN $2::text <> '' AND ($3::boolean OR btrim(description) = '') THEN $2 ELSE description END,
original_name = CASE WHEN $4::text IS NOT NULL THEN COALESCE(original_name, name) ELSE original_name END,
name = COALESCE($4::text, name)
WHERE id = $1`, [modelId, description || '', Boolean(overwriteDescription), name, Boolean(markTagged)]);
});
}
// ---------------------------------------------------------------- collection suggestions
// Records the AI's collection ideas for a model. A name matching an existing
// collection or earlier idea takes its spelling, so ideas group together; ideas
// the model already belongs to, or that you dismissed for it, are skipped.
// Returns the names recorded.
async function suggestCollections(modelId, names) {
const recorded = [];
for (const raw of names) {
const { rows: [known] } = await db.query(
`SELECT name FROM (
SELECT name, 0 AS rank FROM projects WHERE lower(name) = lower($1)
UNION ALL
SELECT name, 1 AS rank FROM collection_suggestions WHERE lower(name) = lower($1)
) n ORDER BY rank, name = lower(name), name LIMIT 1`, [raw]);
// An all-lowercase idea gives way to a capitalised spelling of the same name.
const name = known && !(known.name === known.name.toLowerCase() && raw !== raw.toLowerCase()) ? known.name : raw;
const { rowCount: member } = await db.query(
`SELECT 1 FROM model_projects mp JOIN projects p ON p.id = mp.project_id
WHERE mp.model_id = $1 AND lower(p.name) = lower($2)`, [modelId, name]);
if (member) continue;
const { rowCount } = await db.query(
'INSERT INTO collection_suggestions (model_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING', [modelId, name]);
if (rowCount) recorded.push(name);
}
return recorded;
}
// Collection names the AI should reuse: your collections, then ideas waiting for review.
async function collectionNameHints(limit = 100) {
const { rows } = await db.query(
`SELECT name FROM projects
UNION
SELECT name FROM collection_suggestions WHERE status = 'pending'
ORDER BY name LIMIT $1`, [limit]);
return rows.map((r) => r.name);
}
// Waiting ideas grouped by name: [{ name, project_id (if it exists), models: [{ id, name }] }].
async function listCollectionSuggestions() {
const { rows } = await db.query(`
SELECT (array_agg(COALESCE(p.name, s.name) ORDER BY p.id IS NULL, s.name = lower(s.name), s.name))[1] AS name,
MIN(p.id) AS project_id,
json_agg(DISTINCT jsonb_build_object('id', m.id, 'name', m.name)) AS models
FROM collection_suggestions s
JOIN models m ON m.id = s.model_id AND m.deleted_at IS NULL
LEFT JOIN projects p ON lower(p.name) = lower(s.name)
WHERE s.status = 'pending'
AND NOT EXISTS (
SELECT 1 FROM model_projects mp JOIN projects pp ON pp.id = mp.project_id
WHERE mp.model_id = m.id AND lower(pp.name) = lower(s.name))
GROUP BY lower(s.name)
ORDER BY COUNT(DISTINCT m.id) DESC, 1`);
return rows.map((r) => ({ ...r, models: r.models.sort((a, b) => a.name.localeCompare(b.name)) }));
}
// Adds the models an idea was suggested for (or just modelIds) to the collection of
// that name, creating it if needed. Returns { project, added }, or null if none waited.
async function acceptCollectionSuggestion(name, modelIds = null) {
return db.transaction(async (client) => {
const { rows: waiting } = await client.query(
`SELECT s.model_id FROM collection_suggestions s JOIN models m ON m.id = s.model_id AND m.deleted_at IS NULL
WHERE s.status = 'pending' AND lower(s.name) = lower($1) AND ($2::int[] IS NULL OR s.model_id = ANY($2::int[]))`,
[name, modelIds]);
if (!waiting.length) return null;
let { rows: [project] } = await client.query(
'SELECT * FROM projects WHERE lower(name) = lower($1) ORDER BY id LIMIT 1', [name]);
if (!project) {
// Named with the idea's best spelling, however it was asked for.
({ rows: [project] } = await client.query(
`INSERT INTO projects (name)
SELECT COALESCE((SELECT name FROM collection_suggestions WHERE status = 'pending' AND lower(name) = lower($1)
ORDER BY name = lower(name), name LIMIT 1), $1)
RETURNING *`, [name]));
}
// Sorted, so two of these at once take the model_projects rows in the same
// order rather than deadlocking on each other.
const ids = waiting.map((w) => w.model_id).sort((a, b) => a - b);
for (const id of ids) {
await client.query('INSERT INTO model_projects (model_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [id, project.id]);
}
await client.query(
`UPDATE collection_suggestions SET status = 'accepted' WHERE lower(name) = lower($1) AND model_id = ANY($2::int[])`,
[name, ids]);
return { project, added: ids.length };
});
}
async function dismissCollectionSuggestion(name, modelIds = null) {
const { rowCount } = await db.query(
`UPDATE collection_suggestions SET status = 'dismissed'
WHERE status = 'pending' AND lower(name) = lower($1) AND ($2::int[] IS NULL OR model_id = ANY($2::int[]))`,
[name, modelIds]);
return rowCount;
}
// The library's most used tags, so AI suggestions reuse the same words.
async function topTags(limit = 150) {
const { rows } = await db.query(
`SELECT t.name FROM tags t JOIN model_tags mt ON mt.tag_id = t.id
GROUP BY t.name ORDER BY COUNT(*) DESC, t.name LIMIT $1`, [limit]);
return rows.map((r) => r.name);
} }
async function getAllTags() { async function getAllTags() {
@ -317,7 +487,7 @@ async function getAllTags() {
// ---------------------------------------------------------------- projects // ---------------------------------------------------------------- projects
async function setProjects(client, modelId, projectIds) { async function setProjects(client, modelId, projectIds) {
await client.query('DELETE FROM model_projects WHERE model_id = $1', [modelId]); await client.query('DELETE FROM model_projects WHERE model_id = $1', [modelId]);
for (const pid of projectIds) { for (const pid of [...projectIds].sort((a, b) => a - b)) { // one lock order, always
await client.query( await client.query(
'INSERT INTO model_projects (model_id, project_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', 'INSERT INTO model_projects (model_id, project_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[modelId, pid]); [modelId, pid]);
@ -347,6 +517,20 @@ async function createProject({ name, description }) {
return rows[0]; return rows[0];
} }
// The collection with this name, created if there isn't one yet.
async function findOrCreateProject(name) {
const { rows: [existing] } = await db.query(
'SELECT * FROM projects WHERE lower(name) = lower($1) ORDER BY id LIMIT 1', [name]);
if (existing) return existing;
const { rows: [created] } = await db.query('INSERT INTO projects (name) VALUES ($1) RETURNING *', [name]);
return created;
}
async function addModelToProject(modelId, projectId) {
await db.query('INSERT INTO model_projects (model_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[modelId, projectId]);
}
async function updateProject(id, { name, description }) { async function updateProject(id, { name, description }) {
const { rows } = await db.query( const { rows } = await db.query(
`UPDATE projects SET name = COALESCE($2, name), `UPDATE projects SET name = COALESCE($2, name),
@ -472,15 +656,67 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
return rows[0].id; return rows[0].id;
} }
// ---------------------------------------------------------------- filament
async function listSpools() {
const { rows } = await db.query(
`SELECT * FROM filament_spools ORDER BY material, colour, id`);
return rows;
}
async function createSpool(data) {
const { rows } = await db.query(
`INSERT INTO filament_spools (material, colour, colour_hex, brand, total_g, remaining_g, notes, code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[String(data.material || '').trim(), data.colour || '', data.colour_hex || null, data.brand || '',
data.total_g ?? null, data.remaining_g ?? null, data.notes || '', data.code || null]);
return rows[0];
}
// Only the fields given are changed, so a weight edit can't wipe the notes.
async function updateSpool(id, data) {
const { rows } = await db.query(
`UPDATE filament_spools SET
material = COALESCE($2, material),
colour = COALESCE($3, colour),
colour_hex = COALESCE($4, colour_hex),
brand = COALESCE($5, brand),
total_g = COALESCE($6, total_g),
remaining_g = COALESCE($7, remaining_g),
notes = COALESCE($8, notes),
code = COALESCE($9, code),
date_modified = now()
WHERE id = $1 RETURNING *`,
[id, data.material ?? null, data.colour ?? null, data.colour_hex ?? null, data.brand ?? null,
data.total_g ?? null, data.remaining_g ?? null, data.notes ?? null, data.code ?? null]);
return rows[0] || null;
}
async function deleteSpool(id) {
const { rowCount } = await db.query('DELETE FROM filament_spools WHERE id = $1', [id]);
return rowCount > 0;
}
// The filament slots a model needs, set by hand. Marked 'edited' so the
// background reader treats them as yours and leaves them alone.
async function setModelFilaments(modelId, filaments) {
const { rowCount } = await db.query(
`UPDATE models SET filaments = $2, filaments_status = 'edited', date_modified = now()
WHERE id = $1 AND deleted_at IS NULL`,
[modelId, JSON.stringify(filaments)]);
return rowCount > 0;
}
module.exports = { module.exports = {
getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles, getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles,
updateFileConversion, updateFileConversion,
trashModels, restoreModel, trashedModelIds, countTrashed, trashModels, restoreModel, trashedModelIds, countTrashed,
duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures, duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries, getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries,
getAllTags, getAllTags, replaceRuleTags, removeTagsFrom, applyAiResult, topTags,
getAllProjects, getProject, createProject, updateProject, deleteProject, suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion,
getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject,
getSetting, setSetting, getSetting, setSetting,
listSpools, createSpool, updateSpool, deleteSpool, setModelFilaments,
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword, countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
createPasswordReset, getValidReset, markResetUsed, createPasswordReset, getValidReset, markResetUsed,
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels, getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,

2314
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,14 @@
{ {
"name": "printhive", "name": "printhive",
"version": "1.0.0", "version": "1.0.0",
"description": "Printhive — a web app for organizing 3D-printing files (STL, 3MF, OBJ, ...) with URL import", "description": "Printhive \u2014 a web app for organizing 3D-printing files (STL, 3MF, OBJ, ...) with URL import",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js",
"test": "node --test test/*.test.js" "test": "node --test test/*.test.js",
"lint": "eslint .",
"check": "npm run lint && npm test"
}, },
"keywords": [ "keywords": [
"stl", "stl",
@ -17,6 +19,7 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@huggingface/transformers": "^3.8.1",
"7z-wasm": "^1.2.0", "7z-wasm": "^1.2.0",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@ -24,6 +27,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mqtt": "^5.16.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0", "node-stl": "^0.7.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",
@ -33,6 +37,9 @@
"tar": "^7.5.22" "tar": "^7.5.22"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.11.0",
"globals": "^17.12.0",
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -94,6 +94,22 @@
<span>Duplicates</span> <span>Duplicates</span>
<span class="count" id="duplicatesCount">0</span> <span class="count" id="duplicatesCount">0</span>
</div> </div>
<div class="project-item" data-scope="ai">
<span>AI &amp; auto-tags</span>
<span class="count ai-busy" id="aiBusy" hidden title="AI tagging is running">●</span>
</div>
<div class="project-item" data-scope="watch">
<span>Watched</span>
<span class="count" id="watchCount">0</span>
</div>
<div class="project-item" data-scope="filament">
<span>Filament</span>
<span class="count" id="spoolCount">0</span>
</div>
<div class="project-item" data-scope="printers">
<span>Printers</span>
<span class="count" id="printerCount">0</span>
</div>
<div class="project-item" data-scope="trash"> <div class="project-item" data-scope="trash">
<span>Trash</span> <span>Trash</span>
<span class="count" id="trashCount">0</span> <span class="count" id="trashCount">0</span>
@ -138,6 +154,10 @@
<div id="filterBar" class="filter-bar"> <div id="filterBar" class="filter-bar">
<input type="search" id="searchInput" class="search-input" <input type="search" id="searchInput" class="search-input"
placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models"> placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models">
<div class="seg search-mode" id="searchMode" title="Match the words you type, or find models by what they are">
<button type="button" data-mode="words">Words</button>
<button type="button" data-mode="meaning">Meaning</button>
</div>
<select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select> <select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select>
<select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select> <select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select>
<select id="filterLicense" aria-label="Filter by license"> <select id="filterLicense" aria-label="Filter by license">
@ -146,6 +166,11 @@
<option value="noncommercial">Non-commercial</option> <option value="noncommercial">Non-commercial</option>
<option value="unknown">License unclear</option> <option value="unknown">License unclear</option>
</select> </select>
<select id="filterPrintable" aria-label="Filter by what you can print">
<option value="">Any filament</option>
<option value="yes">Printable now</option>
<option value="no">Missing filament</option>
</select>
<select id="sortBy" aria-label="Sort"> <select id="sortBy" aria-label="Sort">
<option value="newest">Newest first</option> <option value="newest">Newest first</option>
<option value="oldest">Oldest first</option> <option value="oldest">Oldest first</option>
@ -219,6 +244,66 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="settingsForm"> <form id="settingsForm">
<h3 class="settings-group-title">Slicer</h3>
<div class="form-group">
<p class="form-hint">
“Open in slicer” uses <strong id="settingsSlicerName">no slicer yet</strong>. This choice is
remembered in this browser only, since each computer has its own slicers installed.
</p>
<button type="button" class="btn btn-secondary btn-sm" id="chooseSlicerBtn">Choose slicer…</button>
</div>
<h3 class="settings-group-title" id="aiSettings">AI tagging</h3>
<div class="ai-grid">
<div class="form-group">
<label for="aiProvider">Provider</label>
<select id="aiProvider" data-setting="ai_provider">
<option value="deepseek">DeepSeek</option>
<option value="openrouter">OpenRouter</option>
<option value="openai">OpenAI</option>
<option value="custom">Other OpenAI-compatible (Ollama, LM Studio…)</option>
</select>
</div>
<div class="form-group">
<label for="aiApiKey">API key</label>
<input type="password" id="aiApiKey" data-setting="ai_api_key" placeholder="Paste your API key" autocomplete="off">
</div>
<div class="form-group">
<label for="aiModel">Model</label>
<input type="text" id="aiModel" data-setting="ai_model" data-clear-blank autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label for="aiBaseUrl">API address</label>
<input type="text" id="aiBaseUrl" data-setting="ai_base_url" data-clear-blank autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label for="aiPriceInput">Input price ($ per million tokens)</label>
<input type="number" id="aiPriceInput" data-setting="ai_price_input" data-clear-blank min="0" step="0.001">
</div>
<div class="form-group">
<label for="aiPriceOutput">Output price ($ per million tokens)</label>
<input type="number" id="aiPriceOutput" data-setting="ai_price_output" data-clear-blank min="0" step="0.001">
</div>
<div class="form-group">
<label for="aiSendImages">Send thumbnails</label>
<select id="aiSendImages" data-setting="ai_send_images">
<option value="true">Yes: better tags, slightly higher cost</option>
<option value="false">No: names and files only</option>
</select>
</div>
</div>
<p class="form-hint">
Key status: <span class="setting-status" data-status="ai_api_key">—</span>.
<a id="aiKeyLink" target="_blank" rel="noopener" hidden></a>
The key is stored on your server and only sent to the provider. Leave model, address and prices
blank to use the provider's defaults (shown greyed out). Prices are only used to show what runs cost.
</p>
<div class="ai-test-row">
<button type="button" class="btn btn-secondary btn-sm" id="aiTestBtn">Save &amp; test connection</button>
<button type="button" class="btn btn-link clear-setting" data-setting="ai_api_key">Clear key</button>
</div>
<div id="aiTestResult" class="form-hint ai-test-result"></div>
<h3 class="settings-group-title">Import sources</h3> <h3 class="settings-group-title">Import sources</h3>
<div class="form-group"> <div class="form-group">
@ -252,8 +337,10 @@
placeholder="Bearer token from your logged-in session" autocomplete="off"> placeholder="Bearer token from your logged-in session" autocomplete="off">
<p class="form-hint"> <p class="form-hint">
Status: <span class="setting-status" data-status="makerworld_token">—</span>. Status: <span class="setting-status" data-status="makerworld_token">—</span>.
Lets Printhive download files from MakerWorld using your account. Paste the bearer Lets Printhive download files from MakerWorld using your account. In a browser signed in to
token from your logged-in browser session (see instructions in chat). makerworld.com, open the developer tools (Application / Storage → Cookies → makerworld.com)
and copy the value of the <code>token</code> cookie. It expires after a while: paste a new one if
MakerWorld downloads stop working.
</p> </p>
<button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button> <button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button>
</div> </div>
@ -411,13 +498,26 @@
<div id="detailViewer" class="model-viewer"></div> <div id="detailViewer" class="model-viewer"></div>
<div id="detailPlates" class="plate-chips" hidden></div> <div id="detailPlates" class="plate-chips" hidden></div>
<div id="detailFileTabs" class="file-tabs"></div> <div id="detailFileTabs" class="file-tabs"></div>
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button> <div class="preview-actions">
<span id="slicerBar" class="slicer-bar" hidden>
<button type="button" class="btn btn-primary btn-sm" id="openInSlicerBtn">Open in slicer</button>
<button type="button" class="link-btn slicer-change" id="changeSlicerBtn">change slicer</button>
</span>
<button type="button" class="btn btn-secondary btn-sm drag-out-btn" id="dragOutBtn" draggable="true" hidden>⠿ Drag out</button>
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
</div>
<div id="slicerNotice" class="slicer-notice" role="status" hidden></div>
</div> </div>
<div class="file-detail-info"> <div class="file-detail-info">
<div id="detailRenamed" class="renamed-note" hidden></div>
<div class="info-section"> <div class="info-section">
<h3>Source</h3> <h3>Source</h3>
<div id="detailSource"></div> <div id="detailSource"></div>
</div> </div>
<div class="info-section" id="detailPrintableSection">
<h3>Printable now</h3>
<div id="detailPrintable"></div>
</div>
<div class="info-section"> <div class="info-section">
<h3>License</h3> <h3>License</h3>
<div id="detailLicense"></div> <div id="detailLicense"></div>
@ -457,6 +557,7 @@
<div class="form-actions" id="detailActions"> <div class="form-actions" id="detailActions">
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button> <button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
<button class="btn btn-secondary" id="addFilesBtn">Add files</button> <button class="btn btn-secondary" id="addFilesBtn">Add files</button>
<button class="btn btn-secondary" id="aiTagBtn" title="Suggest tags and a description with AI">✦ Tag with AI</button>
<button class="btn btn-primary" id="editModelBtn">Edit</button> <button class="btn btn-primary" id="editModelBtn">Edit</button>
</div> </div>
<div class="form-actions" id="trashActions" hidden> <div class="form-actions" id="trashActions" hidden>
@ -469,6 +570,65 @@
</div> </div>
</div> </div>
<!-- MakerWorld token prompt (shown when MakerWorld refuses the saved token) -->
<div id="tokenModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Update your MakerWorld token</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="tokenForm">
<p id="tokenReason" class="token-reason"></p>
<ol class="token-steps">
<li>In this browser, sign in to <a href="https://makerworld.com" target="_blank" rel="noopener">makerworld.com</a>.</li>
<li>Open the developer tools (<kbd>⌥⌘I</kbd> on a Mac, <kbd>F12</kbd> elsewhere) and go to
Application (Storage in Firefox) → Cookies → https://makerworld.com.</li>
<li>Copy the value of the <code>token</code> cookie and paste it below.</li>
</ol>
<div class="form-group">
<label for="tokenInput">MakerWorld auth token</label>
<input type="password" id="tokenInput" autocomplete="off" spellcheck="false" required>
</div>
<div id="tokenStatus" class="form-hint token-status" role="status"></div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelToken">Not now</button>
<button type="submit" class="btn btn-primary" id="tokenSubmit">Save &amp; download files</button>
</div>
</form>
</div>
</div>
</div>
<!-- Slicer choice (per browser) -->
<div id="slicerModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Choose your slicer</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="slicerForm">
<p class="form-hint slicer-intro">Your browser can't tell which apps are installed, so pick the slicer this computer uses.</p>
<div id="slicerOptions" class="slicer-options"></div>
<div class="form-group" id="slicerTemplateGroup" hidden>
<label for="slicerTemplate">Open-link template</label>
<input type="text" id="slicerTemplate" placeholder="myslicer://open?file={url}" autocomplete="off" spellcheck="false">
<p class="form-hint">
<code>{url}</code> becomes the file's download link, URL-encoded (<code>{rawurl}</code> leaves it as is).
The link works for 10 minutes.
</p>
</div>
<div id="slicerFormError" class="slicer-error"></div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelSlicer">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Model Modal --> <!-- Edit Model Modal -->
<div id="editModal" class="modal"> <div id="editModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@ -518,6 +678,7 @@
</div> </div>
<script type="module" src="viewer.js"></script> <script type="module" src="viewer.js"></script>
<script src="printer-art.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

169
public/printer-art.js Normal file
View file

@ -0,0 +1,169 @@
// Printer models, and simple line drawings of their shapes, drawn for
// Printhive. The drawings suggest a model's form — open frame, enclosed, large
// format — rather than depict any product exactly.
(function () {
const svg = (inner, width = 150) =>
`<svg viewBox="0 0 ${width} 120" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" role="img">${inner}</svg>`;
// One spool in a drawing, filled with what's loaded there: a colour for a
// spool, dashed for an empty slot, plain when nothing is known.
const HEX = /^#[0-9a-f]{6}$/i;
const colourOf = (slot) => (slot && HEX.test(slot.colour || '') ? slot.colour : null);
const spool = (cx, cy, r, slot, active) => {
if (slot && slot.empty) return `<circle cx="${cx}" cy="${cy}" r="${r}" stroke-dasharray="2 3" opacity="0.6"/>`;
const fill = colourOf(slot);
const ring = active ? ' stroke-width="3.4"' : '';
return fill
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"${ring}/><circle cx="${cx}" cy="${cy}" r="${r / 3}"/>`
: `<circle cx="${cx}" cy="${cy}" r="${r}"${ring}/>`;
};
const toolhead = (x, y) => `<rect x="${x}" y="${y}" width="14" height="15" rx="2"/>
<line x1="${x + 7}" y1="${y + 15}" x2="${x + 7}" y2="${y + 20}"/>`;
// Where each shape's spools sit, where its toolhead takes filament in, and
// where an external spool is drawn when it's the one in use.
const amsLiteAt = (x) => [[x - 9, 40, 7], [x + 9, 40, 7], [x - 9, 58, 7], [x + 9, 58, 7]];
const amsTopAt = (x, width) => [0, 1, 2, 3].map((i) => [x + width / 8 + (i * width) / 4, 23, 5]);
const layouts = {
// A1: a bed on a base, an upright gantry, an AMS lite alongside.
open: {
spools: amsLiteAt(126), head: [50, 49], external: [104, 12, 6],
body: () => `
<rect x="12" y="94" width="92" height="10" rx="3"/>
<rect x="24" y="84" width="68" height="6" rx="2"/>
<line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/>
<line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>`,
stand: (x = 126) => `<line x1="${x}" y1="104" x2="${x}" y2="52"/><line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>`,
},
// A1 mini: a single column holding the gantry out over the bed.
openMini: {
spools: amsLiteAt(122), head: [58, 47], external: [29, 18, 6],
body: () => `
<rect x="22" y="94" width="72" height="10" rx="3"/>
<rect x="36" y="84" width="50" height="6" rx="2"/>
<rect x="24" y="30" width="10" height="64" rx="2"/>
<line x1="34" y1="54" x2="90" y2="54"/>`,
stand: () => `<line x1="122" y1="104" x2="122" y2="52"/><line x1="112" y1="104" x2="132" y2="104"/>`,
},
// A2L: the A1's form, stretched to a larger bed.
openLarge: {
spools: amsLiteAt(132), head: [52, 47], external: [112, 10, 6],
body: () => `
<rect x="6" y="94" width="104" height="10" rx="3"/>
<rect x="14" y="84" width="88" height="6" rx="2"/>
<line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/>
<line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>`,
stand: () => `<line x1="132" y1="104" x2="132" y2="52"/><line x1="122" y1="104" x2="142" y2="104"/>`,
},
// P1P: a bare cube frame.
frame: {
width: 140, spools: [], head: [56, 53], external: [124, 18, 6],
body: () => `
<rect x="30" y="38" width="66" height="66" rx="3"/>
<path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/>
<line x1="30" y1="60" x2="96" y2="60"/>
<rect x="40" y="90" width="46" height="5" rx="2"/>`,
},
// P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top.
box: {
width: 126, spools: amsTopAt(30, 66), head: [56, 52], external: [112, 60, 6],
body: () => `
<rect x="30" y="14" width="66" height="18" rx="3"/>
<rect x="28" y="36" width="70" height="70" rx="5"/>
<rect x="36" y="44" width="54" height="46" rx="3"/>
<line x1="36" y1="98" x2="90" y2="98"/>`,
},
// H2S, H2D, H2C: a wide enclosed box with an AMS on top.
large: {
spools: amsTopAt(28, 94), head: [58, 52], secondHead: [78, 52], external: [142, 60, 6],
body: () => `
<rect x="28" y="14" width="94" height="18" rx="3"/>
<rect x="18" y="36" width="114" height="70" rx="5"/>
<rect x="26" y="44" width="98" height="46" rx="3"/>
<line x1="26" y1="98" x2="124" y2="98"/>`,
},
// Anything else: a plain box with a nozzle.
generic: {
width: 140, spools: [], head: [63, 50], external: [122, 22, 6],
body: () => `
<rect x="34" y="30" width="72" height="72" rx="8"/>
<line x1="44" y1="92" x2="96" y2="92"/>`,
},
};
// The filament's path from a spool into the toolhead: a tube curving over
// the frame, outlined so a dark filament still shows on a dark page.
const feed = (from, head, colour, moving) => {
const [sx, sy] = from;
const tx = head[0] + 7;
const ty = head[1];
const lift = Math.max(2, Math.min(sy, ty) - 30);
const d = `M${sx} ${sy} C${sx} ${lift}, ${tx} ${lift}, ${tx} ${ty}`;
return `<g class="feed${moving ? ' is-moving' : ''}">
<path d="${d}" stroke-width="4.6" opacity="0.55"/>
<path d="${d}" stroke="${colour}" stroke-width="2.6"/>
${moving ? `<path class="feed-flow" d="${d}" stroke="#ffffff" stroke-width="1.2" stroke-dasharray="1 7" opacity="0.8"/>` : ''}
</g>`;
};
// `slots`: the first AMS unit's four slots, as { empty, colour }.
// `use`: which is feeding — { slot } for one of those, { external, colour }
// for the external spool — and `moving` while it's printing.
function draw(shapeName, slots = [], use = null, moving = false) {
const L = layouts[shapeName] || layouts.generic;
let parts = L.body();
let feedPart = '';
if (use && use.external && L.external) {
const [x, y, r] = L.external;
parts += spool(x, y, r, { colour: use.colour }, true);
feedPart = feed([x, y], L.head, colourOf(use) || 'currentColor', moving);
} else if (use && use.slot != null && L.spools[use.slot]) {
const [x, y] = L.spools[use.slot];
const colour = colourOf(slots[use.slot]);
if (colour) feedPart = feed([x, y], L.head, colour, moving);
}
if (L.stand) parts += L.stand();
parts += L.spools.map(([x, y, r], i) => spool(x, y, r, slots[i], use && use.slot === i)).join('');
// The tube is drawn behind the toolhead, so it disappears into it.
parts += feedPart + toolhead(...L.head);
if (L.secondHead) parts += toolhead(...L.secondHead);
return svg(parts, L.width);
}
// ams is only a suggestion — a P1S might have an AMS, an AMS 2 Pro or an AMS HT.
const models = [
{ id: 'A1 mini', shape: 'openMini', ams: 'ams_lite' },
{ id: 'A1', shape: 'open', ams: 'ams_lite' },
{ id: 'A2L', shape: 'openLarge', ams: 'ams_lite' },
{ id: 'P1P', shape: 'frame', ams: 'ams' },
{ id: 'P1S', shape: 'box', ams: 'ams' },
{ id: 'P2S', shape: 'box', ams: 'ams_2_pro' },
{ id: 'X1C', shape: 'box', ams: 'ams' },
{ id: 'X1E', shape: 'box', ams: 'ams' },
{ id: 'X2D', shape: 'box', ams: 'ams_2_pro' },
{ id: 'H2S', shape: 'large', ams: 'ams_2_pro' },
{ id: 'H2D', shape: 'large', ams: 'ams_2_pro' },
{ id: 'H2C', shape: 'large', ams: 'ams_2_pro' },
{ id: 'Other', shape: 'generic', ams: 'none' },
];
const amsTypes = [
{ id: 'none', label: 'No AMS (external spool)' },
{ id: 'ams_lite', label: 'AMS lite (full or empty only)' },
{ id: 'ams', label: 'AMS' },
{ id: 'ams_2_pro', label: 'AMS 2 Pro' },
{ id: 'ams_ht', label: 'AMS HT' },
];
const find = (id) => models.find((m) => m.id === id) || null;
window.PRINTER_MODELS = {
models,
amsTypes,
find,
art: (id, slots = [], use = null, moving = false) => draw((find(id) || {}).shape, slots, use, moving),
};
}());

View file

@ -105,7 +105,7 @@ header {
.tags-list { display: flex; flex-wrap: wrap; gap: 6px; } .tags-list { display: flex; flex-wrap: wrap; gap: 6px; }
.tag { .tag {
font-size: 12px; padding: 4px 10px; border-radius: 12px; cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 12px; cursor: pointer; white-space: nowrap;
background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border);
transition: all 0.12s; transition: all 0.12s;
} }
@ -114,7 +114,7 @@ header {
.tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; } .tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
/* ---- form fields ---- */ /* ---- form fields ---- */
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="file"], textarea, select { .search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="number"], input[type="file"], textarea, select {
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
@ -202,7 +202,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.modal-body { padding: 22px; } .modal-body { padding: 22px; }
.form-group { margin-bottom: 18px; } .form-group { margin-bottom: 18px; }
.print-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .print-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } .form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.form-actions .btn { white-space: nowrap; }
/* ---- model detail ---- */ /* ---- model detail ---- */
/* minmax(0, …) keeps a long file name from widening its column and crushing the viewer */ /* minmax(0, …) keeps a long file name from widening its column and crushing the viewer */
@ -250,12 +251,34 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.info-section:last-of-type { margin-bottom: 0; } .info-section:last-of-type { margin-bottom: 0; }
.info-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 9px; } .info-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 9px; }
.info-section p, .info-section div { color: var(--text); font-size: 14px; line-height: 1.6; } .info-section p, .info-section div { color: var(--text); font-size: 14px; line-height: 1.6; }
.info-section .tags { display: flex; flex-wrap: wrap; gap: 6px; }
.info-section .tag { cursor: default; } .info-section .tag { cursor: default; }
.file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); } .file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
.file-row:last-child { border-bottom: none; } .file-row:last-child { border-bottom: none; }
.file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; } .file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; }
.file-row > span:first-child { min-width: 0; overflow-wrap: anywhere; } .file-row > span:first-child { min-width: 0; overflow-wrap: anywhere; }
.file-row[draggable="true"], .entry-row[draggable="true"], .drag-out-btn { cursor: grab; }
.drag-out-btn:active { cursor: grabbing; }
/* ---- slicer hand-off ---- */
.preview-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
.preview-actions .btn-sm { align-self: auto; }
.slicer-bar { display: inline-flex; align-items: center; gap: 10px; }
.slicer-change { font-size: 12px; color: var(--text-dim); }
.slicer-notice { font-size: 13px; line-height: 1.5; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; }
.slicer-notice.is-warning { border-color: var(--accent); color: var(--text); }
.slicer-notice-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.slicer-notice-actions .btn-sm { align-self: auto; }
.slicer-intro { margin: 0 0 14px; }
.slicer-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.slicer-option { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; transition: border-color 0.12s, background 0.12s; }
.slicer-option:hover { border-color: var(--border-strong); }
.slicer-option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.slicer-option input { width: auto; margin-top: 3px; accent-color: var(--accent); flex-shrink: 0; }
.slicer-option strong { display: block; font-size: 14px; font-weight: 500; color: var(--text); }
.slicer-option em { display: block; font-style: normal; font-size: 12px; color: var(--text-dim); }
.slicer-error { color: var(--danger); font-size: 13px; min-height: 1em; }
/* ---- import / settings ---- */ /* ---- import / settings ---- */
.import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; } .import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; }
@ -354,3 +377,162 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.dup-card .btn { margin-top: 6px; } .dup-card .btn { margin-top: 6px; }
.rec-badge { align-self: flex-start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; background: rgba(76, 195, 138, 0.16); color: var(--success); } .rec-badge { align-self: flex-start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; background: rgba(76, 195, 138, 0.16); color: var(--success); }
.warn { color: var(--accent); } .warn { color: var(--accent); }
/* ---- AI & automatic tags ---- */
.ai-panel { display: flex; flex-direction: column; gap: 18px; max-width: 920px; }
.ai-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
.ai-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ai-card h3 { font-size: 15px; font-weight: 600; }
.ai-card h4 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 6px; }
.ai-card .form-hint { margin: 0; }
.ai-callout { font-size: 13px; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.ai-callout.is-warning { border-color: var(--accent); color: var(--text); }
.ai-callout code { font-family: var(--mono); font-size: 12px; color: var(--text); }
.ai-callout .btn-sm, .ai-run-actions .btn-sm, .ai-start-row .btn-sm, .rule-form .btn-sm, .ai-test-row .btn-sm { align-self: auto; }
.ai-start { display: flex; flex-direction: column; gap: 10px; }
.ai-start label { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--text-muted); cursor: pointer; }
.ai-scope { display: flex; flex-wrap: wrap; gap: 8px 22px; }
.ai-start input[type="radio"], .ai-start input[type="checkbox"], .switch input { width: auto; margin: 0; accent-color: var(--accent); }
.count-inline { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
.money-input { display: inline-flex; align-items: center; gap: 4px; }
.money-input input { width: 100px; padding: 6px 8px; }
.ai-start-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.ai-runs { display: flex; flex-direction: column; gap: 10px; }
.ai-run { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; background: var(--surface-2); }
.ai-run-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--text); }
.ai-run-meta { color: var(--text-dim); font-size: 12px; }
.ai-run-actions { margin-left: auto; display: flex; gap: 8px; }
.ai-run-stats { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
.ai-run-error { color: var(--danger); font-size: 13px; }
.pill { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 8px; border-radius: 10px; background: var(--surface-3); color: var(--text-muted); }
.pill-running { background: var(--accent-soft); color: var(--accent); }
.pill-done { background: rgba(76, 195, 138, 0.16); color: var(--success); }
.pill-failed { background: var(--danger-soft); color: var(--danger); }
.progress { height: 6px; background: var(--surface-3); border-radius: 3px; overflow: hidden; }
.progress span { display: block; height: 100%; background: var(--accent); transition: width 0.4s; }
.ai-feed { list-style: none; display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; font-size: 13px; }
.ai-feed li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
.ai-card .tag { cursor: default; padding: 2px 8px; font-size: 11px; }
.rule-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
.rule-tags { display: flex; flex-wrap: wrap; gap: 5px; }
.rule-subhead { margin-top: 4px; }
.rule-list { display: flex; flex-direction: column; }
.rule-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
.rule-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rule-form input { flex: 1 1 160px; width: auto; padding: 7px 10px; font-size: 13px; }
.switch { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-muted); cursor: pointer; }
.tag.tag-ai::before { content: "✦ "; color: var(--accent); }
.tag.tag-rule { border-style: dashed; }
.ai-busy { background: transparent !important; color: var(--accent) !important; font-size: 10px; animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.35; } }
.filter-bar .search-mode { align-self: stretch; align-items: center; }
.filter-bar .search-mode button { padding: 5px 10px; font-size: 12px; }
.ai-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.ai-grid .form-group { margin-bottom: 12px; }
.ai-test-row { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
.ai-test-result { min-height: 1em; margin-top: 8px; }
.ai-test-result.is-ok { color: var(--success); }
.ai-test-result.is-error { color: var(--danger); }
@media (max-width: 640px) { .ai-grid { grid-template-columns: 1fr; } .ai-run-actions { margin-left: 0; } }
.renamed-note { font-size: 13px; color: var(--text); background: var(--accent-soft); border: 1px solid rgba(240, 151, 42, 0.35); border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 16px; }
.renamed-from { font-size: 12px; color: var(--text-dim); }
.ai-tasks { border: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.ai-tasks legend { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 8px; }
.ai-start .ai-subtask { margin-left: 26px; }
.ai-start label:has(input:disabled) { opacity: 0.5; cursor: default; }
.suggestion-list { display: flex; flex-direction: column; gap: 10px; }
.suggestion { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 14px; background: var(--surface-2); display: flex; flex-direction: column; gap: 6px; }
.suggestion-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
.suggestion-actions { margin-left: auto; display: flex; gap: 8px; }
.suggestion-actions .btn-sm { align-self: auto; }
.suggestion-models { font-size: 13px; color: var(--text-muted); line-height: 1.7; }
.suggested-collections { font-size: 12px; color: var(--accent); }
.token-reason { font-size: 14px; color: var(--text); line-height: 1.5; margin-bottom: 12px; }
.token-steps { margin: 0 0 16px 18px; font-size: 13px; color: var(--text-muted); line-height: 1.7; }
.token-steps code, .token-steps kbd { font-family: var(--mono); font-size: 12px; }
.token-status { min-height: 1em; }
.token-status.is-error { color: var(--danger); }
.watch-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.watch-form input[type="url"] { flex: 1 1 320px; width: auto; }
.watch-form label { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-muted); }
.watch-form select { width: auto; padding: 7px 10px; font-size: 13px; }
.watch-form input[type="checkbox"] { width: auto; accent-color: var(--accent); }
.watch-form .btn { align-self: auto; }
.watch-list { display: flex; flex-direction: column; gap: 10px; }
.watch { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
.watch.is-paused { opacity: 0.65; }
.watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
.watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
.watch-actions .btn-sm { align-self: auto; }
.slot-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
.slot-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.slot-edit .slot-material { width: 110px; padding: 5px 8px; font-size: 13px; }
.slot-edit .slot-colour { width: 42px; padding: 2px; }
.slot-edit .slot-colour:disabled { opacity: 0.35; }
.slot-any { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); }
.slot-any input { width: auto; accent-color: var(--accent); }
.print-badge.yours { background: color-mix(in srgb, #6366f1 20%, transparent); color: #a5b4fc; }
.slot-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.print-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; }
.print-badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
.print-badge.no { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #fbbf24; }
.spool-dot { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--border); display: inline-block; }
.spool .watch-actions label { display: inline-flex; align-items: center; gap: 6px; }
.spool .spool-remaining { width: 90px; padding: 5px 8px; font-size: 13px; }
.ai-failures summary { cursor: pointer; font-size: 13px; color: var(--text-muted); }
.ai-failures summary:hover { color: var(--text); }
.watch-pending summary { cursor: pointer; font-size: 13px; color: var(--text-muted); }
.watch-pending summary:hover { color: var(--text); }
.watch-pending .watch-recent { margin-top: 6px; }
.watch-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
.watch-recent { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); max-height: 180px; overflow-y: auto; }
#watchStatus.is-error { color: var(--danger); }
/* ---- Printers ---- */
.printer-cards { display: flex; flex-direction: column; gap: 18px; }
.printer-card { flex-direction: row; align-items: flex-start; gap: 20px; }
.printer-side { flex: 0 0 180px; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.printer-art { color: var(--text-muted); padding: 20px 10px 0; }
.loaded-list { min-width: 0; list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--text-muted); }
.loaded-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
.loaded-slot { font-family: var(--mono); color: var(--text-dim); min-width: 62px; }
.spool-dot.is-empty { background: transparent; border: 1px dashed var(--text-dim); }
.printer-art svg { width: 100%; height: auto; display: block; }
.printer-form { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 14px; }
.printer-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); }
.printer-form input, .printer-form select { width: 100%; }
.printer-title, .printer-actions, .printer-result { grid-column: 1 / -1; }
.printer-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.printer-actions .btn-sm { align-self: auto; }
.printer-result:empty { display: none; }
.spool-tag code { color: var(--text-muted); }
@media (max-width: 600px) {
.printer-card { flex-direction: column; }
.printer-side { flex-basis: auto; width: 100%; flex-direction: row; flex-wrap: wrap; align-items: flex-start; }
.printer-art { width: 140px; padding: 0; }
}
.printer-ask { grid-column: 1 / -1; color: var(--accent); }
.loaded-list .spool-dot { flex-shrink: 0; width: 12px; height: 12px; }
.printer-status { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-muted); }
.printer-status:empty { display: none; }
.status-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.status-pill { flex-shrink: 0; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 8px; border-radius: 10px; background: var(--surface-3); color: var(--text-muted); }
.status-pill.is-busy { background: var(--accent-soft); color: var(--accent); }
.status-pill.is-warn { background: var(--accent-soft); color: var(--text); }
.status-pill.is-ok { background: rgba(76, 195, 138, 0.16); color: var(--success); }
.status-pill.is-bad, .status-pill.is-off { background: var(--danger-soft); color: var(--danger); }
.status-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.status-bar { height: 6px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
.status-bar span { display: block; height: 100%; background: var(--accent); transition: width 0.6s ease; }
.status-facts { font-family: var(--mono); font-size: 12px; }
.status-problems { color: var(--danger); font-size: 12px; }
.loaded-list li.is-active { color: var(--text); }
.loaded-active { color: var(--accent); font-size: 11px; }
.status-using { display: flex; align-items: center; gap: 6px; color: var(--text); }
.status-using .spool-dot { width: 12px; height: 12px; flex-shrink: 0; }
.feed-flow { animation: feed-flow 0.8s linear infinite; }
@keyframes feed-flow { to { stroke-dashoffset: -8; } }
@media (prefers-reduced-motion: reduce) { .feed-flow { animation: none; } }
.printer-camera { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; }
.printer-camera img { width: 100%; max-width: 520px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2); display: block; }
.printer-camera img:not([src]) { display: none; }

793
server.js
View file

@ -23,6 +23,37 @@ const fingerprint = require('./lib/fingerprint');
const duplicates = require('./lib/duplicates'); const duplicates = require('./lib/duplicates');
const archives = require('./lib/archives'); const archives = require('./lib/archives');
const thumbnails = require('./lib/thumbnails'); const thumbnails = require('./lib/thumbnails');
const handoff = require('./lib/handoff');
const ai = require('./lib/ai');
const aiRuns = require('./lib/aiRuns');
const collectionWatch = require('./lib/collectionWatch');
const autotag = require('./lib/autotag');
const filaments = require('./lib/filaments');
const filamentPalette = require('./db/bambu-filaments.json');
const printer = require('./lib/printer');
const printers = require('./lib/printers');
const spoolSync = require('./lib/spoolSync');
const printerLive = require('./lib/printerLive');
const printerCamera = require('./lib/printerCamera');
const downloadBlock = require('./lib/downloadBlock');
// What a printer has loaded → its card and the Filament page.
async function syncTrays(printerId, trays) {
const p = await printers.get(printerId);
if (!p || !p.serial) return null;
await printers.recordLoaded(p.id, trays);
return spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments,
{ measures: printers.measures(p.ams_type) });
}
printerLive.setTraysHandler(syncTrays);
// Opens, closes or reopens live connections to match the printers saved.
async function reconnectPrinters() {
printerLive.reconcile(await printers.listWithCodes());
}
const embeddings = require('./lib/embeddings');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@ -107,8 +138,22 @@ const SETTINGS = {
smtp_user: { env: 'SMTP_USER', label: 'SMTP username', secret: false }, smtp_user: { env: 'SMTP_USER', label: 'SMTP username', secret: false },
smtp_pass: { env: 'SMTP_PASS', label: 'SMTP password', secret: true }, smtp_pass: { env: 'SMTP_PASS', label: 'SMTP password', secret: true },
smtp_from: { env: 'SMTP_FROM', label: 'From address', secret: false }, smtp_from: { env: 'SMTP_FROM', label: 'From address', secret: false },
ai_provider: { env: 'AI_PROVIDER', label: 'AI provider', secret: false },
ai_base_url: { env: 'AI_BASE_URL', label: 'AI API address', secret: false },
ai_api_key: { env: 'AI_API_KEY', label: 'AI API key', secret: true },
ai_model: { env: 'AI_MODEL', label: 'AI model', secret: false },
ai_send_images: { env: 'AI_SEND_IMAGES', label: 'Send thumbnails to the AI', secret: false },
ai_price_input: { env: 'AI_PRICE_INPUT', label: 'Input price ($ per million tokens)', secret: false },
ai_price_output: { env: 'AI_PRICE_OUTPUT', label: 'Output price ($ per million tokens)', secret: false },
}; };
const AI_SETTING_KEYS = Object.keys(SETTINGS).filter((k) => k.startsWith('ai_'));
async function resolveAiConfig() {
const values = {};
for (const key of AI_SETTING_KEYS) values[key] = (await resolveSetting(key)).value;
return ai.configFrom(values);
}
async function resolveSetting(key) { async function resolveSetting(key) {
const fromDb = await store.getSetting(key); const fromDb = await store.getSetting(key);
if (fromDb) return { value: fromDb, source: 'database' }; if (fromDb) return { value: fromDb, source: 'database' };
@ -299,6 +344,143 @@ app.put('/api/settings', async (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// ----------------------------------------------------------------- filament
// The spools you have. What a model needs is read from its 3MF in the
// background and kept on the model itself, so matching is a plain comparison.
// ----------------------------------------------------------------- printers
// Access codes go in but never come back out: responses carry `codeSet`.
app.get('/api/printers', async (req, res, next) => {
try { res.json(await printers.list()); } catch (e) { next(e); }
});
app.post('/api/printers', async (req, res, next) => {
try {
const saved = await printers.create(req.body || {});
await reconnectPrinters();
res.status(201).json(saved);
} catch (e) { next(e); }
});
app.put('/api/printers/:id', async (req, res, next) => {
try {
const saved = await printers.update(Number(req.params.id), req.body || {});
if (!saved) return res.status(404).json({ error: 'No such printer' });
await reconnectPrinters();
res.json(saved);
} catch (e) { next(e); }
});
app.delete('/api/printers/:id', async (req, res, next) => {
try {
if (!(await printers.remove(Number(req.params.id)))) return res.status(404).json({ error: 'No such printer' });
await reconnectPrinters();
res.json({ message: 'Printer removed' });
} catch (e) { next(e); }
});
// The chamber camera as a stream of pictures, which an <img> can show directly.
// The connection to the printer is made here, so the access code stays put, and
// it's dropped as soon as the last viewer looks away. Nothing is recorded.
app.get('/api/printers/:id/camera', async (req, res, next) => {
try {
const p = await printers.get(Number(req.params.id), { withCode: true });
if (!p) return res.status(404).json({ error: 'No such printer' });
if (!p.host || !p.access_code) return res.status(400).json({ error: 'Fill in the printer’s address and access code first.' });
const boundary = 'printhiveframe';
res.writeHead(200, {
'Content-Type': `multipart/x-mixed-replace; boundary=${boundary}`,
'Cache-Control': 'no-store, no-cache, must-revalidate',
Connection: 'close',
Pragma: 'no-cache',
});
const unsubscribe = printerCamera.subscribe(p.id, { host: p.host, accessCode: p.access_code },
(frame) => {
if (res.writableEnded) return;
res.write(`--${boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
res.write(frame);
res.write('\r\n');
},
() => { if (!res.writableEnded) res.end(); });
req.on('close', () => { unsubscribe(); if (!res.writableEnded) res.end(); });
} catch (e) { next(e); }
});
// Tries the printer's chamber camera and says whether a picture arrived.
// P1 and A1 series only, and the printer needs "LAN Mode Liveview" switched on.
app.post('/api/printers/:id/camera/test', async (req, res, next) => {
try {
const p = await printers.get(Number(req.params.id), { withCode: true });
if (!p) return res.status(404).json({ error: 'No such printer' });
if (!p.host || !p.access_code) return res.json({ ok: false, error: 'Fill in the printer’s address and access code first.' });
res.json(await printerCamera.testCamera({ host: p.host, accessCode: p.access_code }));
} catch (e) { next(e); }
});
// What every printer is doing right now, from the live connections.
app.get('/api/printers/live', (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(printerLive.snapshots());
});
// Asks one printer for a full report, says what it reported, and updates the
// spools it has loaded. ?debug=1 instead makes a separate short connection and
// adds one tray's raw fields — filament data, no credentials.
app.post('/api/printers/:id/test', async (req, res, next) => {
try {
const p = await printers.get(Number(req.params.id), { withCode: true });
if (!p) return res.status(404).json({ error: 'No such printer' });
const missing = [!p.host && 'address', !p.serial && 'serial number', !p.access_code && 'access code']
.filter(Boolean);
if (missing.length) return res.json({ connected: false, error: `Fill in the printer’s ${missing.join(', ')} first.` });
if (req.query.debug === '1') {
const result = await printer.probe({ host: p.host, serial: p.serial, accessCode: p.access_code, debug: true });
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
if (result.connected && (result.ams || result.external)) result.sync = await syncTrays(p.id, trays);
return res.json(result);
}
await reconnectPrinters();
res.json(await printerLive.refresh(p.id));
} catch (e) { next(e); }
});
// The maker's filament list, for picking a spool without typing hex codes.
// Static, so it's cached hard rather than re-read.
app.get('/api/filament/palette', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400');
res.json(filamentPalette);
});
app.get('/api/filament', async (req, res, next) => {
try {
res.json({ spools: await store.listSpools(), pending: await filaments.pendingCount() });
} catch (e) { next(e); }
});
app.post('/api/filament', async (req, res, next) => {
try {
const material = String((req.body || {}).material || '').trim();
if (!material) return res.status(400).json({ error: 'A spool needs a material (PLA, PETG, …).' });
res.status(201).json(await store.createSpool({ ...req.body, material }));
} catch (e) { next(e); }
});
app.put('/api/filament/:id', async (req, res, next) => {
try {
const spool = await store.updateSpool(Number(req.params.id), req.body || {});
if (!spool) return res.status(404).json({ error: 'No such spool' });
res.json(spool);
} catch (e) { next(e); }
});
app.delete('/api/filament/:id', async (req, res, next) => {
try {
if (!(await store.deleteSpool(Number(req.params.id)))) return res.status(404).json({ error: 'No such spool' });
res.json({ message: 'Spool removed' });
} catch (e) { next(e); }
});
// ----------------------------------------------------------------- models // ----------------------------------------------------------------- models
app.get('/api/models', async (req, res, next) => { app.get('/api/models', async (req, res, next) => {
try { try {
@ -356,6 +538,7 @@ app.put('/api/models/:id', async (req, res, next) => {
if (updates.license !== undefined) updates.commercial_use = await resolveCommercial(updates.license); if (updates.license !== undefined) updates.commercial_use = await resolveCommercial(updates.license);
const model = await store.updateModel(req.params.id, updates); const model = await store.updateModel(req.params.id, updates);
if (!model) return res.status(404).json({ error: 'Model not found' }); if (!model) return res.status(404).json({ error: 'Model not found' });
fingerprint.kick(); // names and tags feed automatic tags and search
res.json(publicModel(model)); res.json(publicModel(model));
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -402,6 +585,34 @@ async function refreshMetadata(model) {
// Re-download files from the source into an existing model (recovers imports // Re-download files from the source into an existing model (recovers imports
// that came back empty/partial, e.g. when FlareSolverr was flaky). Only adds // that came back empty/partial, e.g. when FlareSolverr was flaky). Only adds
// files not already present. // files not already present.
// The filament slots a model needs — for when the project's own list is wrong,
// or you plan to print it in something else. Saved as yours, so the background
// reader won't overwrite it.
app.put('/api/models/:id/filaments', async (req, res, next) => {
try {
const sent = (req.body || {}).filaments;
const cleaned = filaments.cleanSlots(sent);
if (!cleaned) return res.status(400).json({ error: 'Send { filaments: [{ type, colour }] }' });
if (sent.length > 32) return res.status(400).json({ error: 'That is more slots than any printer has.' });
if (!(await store.setModelFilaments(Number(req.params.id), cleaned))) {
return res.status(404).json({ error: 'Model not found' });
}
res.json(publicModel(await store.getModel(req.params.id)));
} catch (e) { next(e); }
});
// Throw away your edits and read the project's own list again.
app.delete('/api/models/:id/filaments', async (req, res, next) => {
try {
const { rowCount } = await db.query(
`UPDATE models SET filaments = NULL, filaments_status = NULL WHERE id = $1 AND deleted_at IS NULL`,
[Number(req.params.id)]);
if (!rowCount) return res.status(404).json({ error: 'Model not found' });
fingerprint.kick(); // the reader picks it up again
res.json({ message: 'Back to the project’s own filament list' });
} catch (e) { next(e); }
});
app.post('/api/models/:id/download-files', async (req, res, next) => { app.post('/api/models/:id/download-files', async (req, res, next) => {
try { try {
const model = await store.getModel(req.params.id); const model = await store.getModel(req.params.id);
@ -415,6 +626,9 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
return res.status(200).json({ ...publicModel(model), added: 0 }); return res.status(200).json({ ...publicModel(model), added: 0 });
} }
const updated = await store.addFiles(model.id, newFiles); const updated = await store.addFiles(model.id, newFiles);
// If a watched collection imported this one without its files, it's fine now.
await db.query(`UPDATE watched_collection_items SET status = 'imported', error = NULL
WHERE model_id = $1 AND status = 'partial'`, [model.id]);
fingerprint.kick(); fingerprint.kick();
res.json({ ...publicModel(updated), added: newFiles.length }); res.json({ ...publicModel(updated), added: newFiles.length });
} catch (e) { next(e); } } catch (e) { next(e); }
@ -424,6 +638,7 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
// original name is already present. // original name is already present.
async function fetchSourceFiles(model, skipNames) { async function fetchSourceFiles(model, skipNames) {
if (model.source_site === 'makerworld') { if (model.source_site === 'makerworld') {
await requireMakerworldSettings(); // before the page fetch, which fails without FlareSolverr
const html = await fetchPageHtml(model.source_url); const html = await fetchPageHtml(model.source_url);
return downloadMakerworldFiles(html, skipNames); return downloadMakerworldFiles(html, skipNames);
} }
@ -590,6 +805,54 @@ app.get('/api/entries/:id/raw', async (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// ----------------------------------------------------------------- slicer hand-off
// "Open in slicer" launches the slicer with a link it downloads itself. The slicer
// has no session cookie, so the link carries a short-lived grant for one file.
async function handoffTarget(kind, id) {
if (!Number.isInteger(Number(id))) return null;
if (kind === 'file') {
const file = await store.getFile(Number(id));
return file && { name: file.original_name, format: file.format, resolve: async () => path.resolve(file.file_path) };
}
if (kind === 'entry') {
const entry = await store.getArchiveEntry(Number(id));
return entry && {
name: path.posix.basename(entry.path), format: entry.format,
resolve: () => archives.extractEntry(entry.archive, entry),
};
}
return null;
}
// -> { path: "/dl/<token>/<name>", name, expiresIn }. The browser adds its own
// origin, which is the address this machine already reaches the server on.
app.post('/api/handoff', async (req, res, next) => {
try {
const { kind, id } = req.body || {};
const target = await handoffTarget(kind, id);
if (!target) return res.status(404).json({ error: 'File not found' });
if (!handoff.SLICEABLE.includes(target.format)) {
return res.status(415).json({ error: `.${target.format} files can't be opened in a slicer` });
}
const token = handoff.createToken(await auth.getSecret(), { kind, id: Number(id) });
res.json({ path: `/dl/${token}/${handoff.urlName(target.name)}`, name: target.name, expiresIn: handoff.TTL_SECONDS });
} catch (e) { next(e); }
});
// Outside /api on purpose: the token is the only credential.
app.get('/dl/:token/:name', async (req, res, next) => {
try {
const grant = handoff.readToken(await auth.getSecret(), req.params.token);
if (!grant) return res.status(403).type('text/plain').send('This download link has expired. Open the model in Printhive again.');
const target = await handoffTarget(grant.kind, grant.id);
if (!target) return res.status(404).type('text/plain').send('File not found');
const abs = await target.resolve();
if (!fs.existsSync(abs)) return res.status(404).type('text/plain').send('File missing on disk');
res.set({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
res.download(abs, target.name);
} catch (e) { next(e); }
});
// Serve a 3MF as GLB, converting on first request (cached thereafter). This lets // Serve a 3MF as GLB, converting on first request (cached thereafter). This lets
// the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox. // the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox.
// ?plate=N converts just that plate of a slicer project. // ?plate=N converts just that plate of a slicer project.
@ -723,85 +986,109 @@ function removeDerivedFiles(fileIds, entryIds) {
// Unified import. Thingiverse downloads files automatically (needs a token); // Unified import. Thingiverse downloads files automatically (needs a token);
// Printables / MakerWorld / any other URL import page metadata + preview image, // Printables / MakerWorld / any other URL import page metadata + preview image,
// and the user attaches the files afterwards. // and the user attaches the files afterwards.
app.post('/api/import', async (req, res, next) => { // Imports one model URL: its details, its files where they can be downloaded, and
// its license. Returns { model, existing, metadataFetched, filesError, filesErrorReason }.
// Used by the import endpoint and by watched collections.
// withFiles: false imports the page only (name, description, thumbnail, licence).
// Watched collections use it — MakerWorld refuses more than a couple of downloads
// in a row, so files are fetched later, per model, with "Get files".
async function importFromUrl(url, { projects = [], withFiles = true } = {}) {
const detected = generic.detectSite(url);
if (!detected) throw Object.assign(new Error('That does not look like a valid URL.'), { status: 400 });
if (detected.site === 'thingiverse') {
return { model: await importThingiverse(url, projects), metadataFetched: true };
}
// Metadata-only import for everything else.
if (detected.id) {
const existing = await store.findModelBySource(detected.site, detected.id);
if (existing) return { model: publicModel(existing), existing: true };
}
// Best-effort: read the page (via FlareSolverr if it's Cloudflare-blocked).
// If it still can't be read, fall back to a stub so it's not a dead end.
let html = null;
let meta = { title: null, description: '', image: null, author: null };
let metadataFetched = false;
try { try {
const url = (req.body.url || '').trim(); html = await fetchPageHtml(url);
const projects = req.body.projects || []; meta = generic.parseMetadata(html, url);
const detected = generic.detectSite(url); // Printables og:title carries a " | Download free STL model | Printables.com" suffix.
if (!detected) return res.status(400).json({ error: 'That does not look like a valid URL.' }); if (detected.site === 'printables' && meta.title) meta.title = meta.title.split(' | ')[0].trim();
// cubee3d (Wix) serves a generic site-wide og:title, so use the URL slug instead.
if (detected.site === 'cubee3d') meta.title = generic.nameFromUrl(url);
metadataFetched = Boolean(meta.title);
} catch (err) {
console.warn('Metadata fetch failed, creating stub model:', err.message);
}
if (detected.site === 'thingiverse') { // Download the actual files where we can (MakerWorld via FlareSolverr+token,
const model = await importThingiverse(url, projects); // Printables via its public GraphQL API), and capture the license.
return res.status(201).json(model); let files = [];
} let filesError = null; // why no files could be downloaded, shown after the import
let filesErrorReason = null; // makerworld_token: the app offers to update the token
// Metadata-only import for everything else. let license = meta.license || null;
if (detected.id) { let commercial;
const existing = await store.findModelBySource(detected.site, detected.id); if (detected.site === 'makerworld' && html) {
if (existing) return res.status(409).json({ error: 'Already imported', model: publicModel(existing) }); if (withFiles) {
}
// Best-effort: read the page (via FlareSolverr if it's Cloudflare-blocked).
// If it still can't be read, fall back to a stub so it's not a dead end.
let html = null;
let meta = { title: null, description: '', image: null, author: null };
let metadataFetched = false;
try {
html = await fetchPageHtml(url);
meta = generic.parseMetadata(html, url);
// Printables og:title carries a " | Download free STL model | Printables.com" suffix.
if (detected.site === 'printables' && meta.title) meta.title = meta.title.split(' | ')[0].trim();
// cubee3d (Wix) serves a generic site-wide og:title, so use the URL slug instead.
if (detected.site === 'cubee3d') meta.title = generic.nameFromUrl(url);
metadataFetched = Boolean(meta.title);
} catch (err) {
console.warn('Metadata fetch failed, creating stub model:', err.message);
}
// Download the actual files where we can (MakerWorld via FlareSolverr+token,
// Printables via its public GraphQL API), and capture the license.
let files = [];
let license = meta.license || null;
let commercial = null;
if (detected.site === 'makerworld' && html) {
files = await downloadMakerworldFiles(html).catch((err) => { files = await downloadMakerworldFiles(html).catch((err) => {
console.warn('MakerWorld file download failed:', err.message); console.warn('MakerWorld file download failed:', err.message);
filesError = err.message;
filesErrorReason = err.reason || null;
return []; return [];
}); });
license = makerworld.parseLicense(html) || license;
commercial = await resolveCommercial(license);
} else if (detected.site === 'printables' && detected.id) {
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
console.warn('Printables file download failed:', err.message);
return { files: [] };
});
files = result.files;
if (result.license) license = result.license;
commercial = result.commercial ?? await resolveCommercial(license);
} else if (detected.site === 'cubee3d') {
// Auth-gated Wix site — files added manually. You hold a commercial license.
license = 'Cubee3D Commercial License';
commercial = await resolveCommercial(license, true);
} else {
commercial = await resolveCommercial(license);
} }
license = makerworld.parseLicense(html) || license;
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files); commercial = await resolveCommercial(license);
const model = await store.createModel({ } else if (detected.site === 'printables' && detected.id) {
name: meta.title || generic.nameFromUrl(url), const result = await downloadPrintablesFiles(detected.id).catch((err) => {
description: meta.description || '', console.warn('Printables file download failed:', err.message);
source_site: detected.site, filesError = err.message;
source_url: url, return { files: [] };
source_id: detected.id, });
license, files = result.files;
commercial_use: commercial, if (result.license) license = result.license;
designer: meta.author ? { name: meta.author, source_site: detected.site } : null, commercial = result.commercial ?? await resolveCommercial(license);
thumbnail_path: thumbnail, } else if (detected.site === 'cubee3d') {
tags: [], // Auth-gated Wix site — files added manually. You hold a commercial license.
projects, license = 'Cubee3D Commercial License';
files, commercial = await resolveCommercial(license, true);
} else {
commercial = await resolveCommercial(license);
}
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
const model = await store.createModel({
name: meta.title || generic.nameFromUrl(url),
description: meta.description || '',
source_site: detected.site,
source_url: url,
source_id: detected.id,
license,
commercial_use: commercial,
designer: meta.author ? { name: meta.author, source_site: detected.site } : null,
thumbnail_path: thumbnail,
tags: [],
projects,
files,
});
fingerprint.kick();
return { model: publicModel(model), metadataFetched, filesError, filesErrorReason };}
// Unified import. Thingiverse downloads files automatically (needs a token);
// Printables / MakerWorld / any other URL import page metadata + preview image,
// and the user attaches the files afterwards.
app.post('/api/import', async (req, res, next) => {
try {
const result = await importFromUrl((req.body.url || '').trim(), { projects: req.body.projects || [] });
if (result.existing) return res.status(409).json({ error: 'Already imported', model: result.model });
res.status(201).json({
...result.model,
metadataOnly: (result.model.files || []).length === 0,
metadataFetched: Boolean(result.metadataFetched),
filesError: result.filesError || null,
filesErrorReason: result.filesErrorReason || null,
}); });
fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -850,14 +1137,33 @@ async function resolveCommercial(licenseName, fallback) {
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr). // Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
// skipNames: original filenames already present (so a re-download only adds new ones). // skipNames: original filenames already present (so a re-download only adds new ones).
async function downloadMakerworldFiles(html, skipNames = new Set()) { // MakerWorld downloads need both a FlareSolverr address and your auth token.
// Errors carry a reason the app can act on: makerworld_token opens the token prompt.
async function requireMakerworldSettings() {
const { value: token } = await resolveSetting('makerworld_token'); const { value: token } = await resolveSetting('makerworld_token');
const { value: flareUrl } = await resolveSetting('flaresolverr_url'); const { value: flareUrl } = await resolveSetting('flaresolverr_url');
if (!token || !flareUrl) return []; if (!flareUrl) {
throw Object.assign(new Error('Add your FlareSolverr URL in Settings to download MakerWorld files (MakerWorld is behind Cloudflare).'),
{ status: 400, reason: 'makerworld_settings' });
}
if (!token) {
throw Object.assign(new Error('Add your MakerWorld auth token to download MakerWorld files.'), { status: 400, reason: 'makerworld_token' });
}
return { token, flareUrl };
}
// Downloads a MakerWorld model's files. When nothing could be downloaded it throws
// with the reason (missing settings, an expired token, …) so the import can say why.
async function downloadMakerworldFiles(html, skipNames = new Set()) {
const { token, flareUrl } = await requireMakerworldSettings();
const instances = makerworld.parseInstances(html); const instances = makerworld.parseInstances(html);
if (!instances.length) {
throw Object.assign(new Error('Couldn’t find any printable files on the MakerWorld page.'), { status: 502 });
}
ensureUploadDir(); ensureUploadDir();
const files = []; const files = [];
const problems = [];
for (let i = 0; i < instances.length; i++) { for (let i = 0; i < instances.length; i++) {
if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls
const inst = instances[i]; const inst = instances[i];
@ -866,17 +1172,44 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
dl = await makerworld.resolveDownload(flareUrl, inst.id, token); dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
} catch (err) { } catch (err) {
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message); console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
// A page where JSON was expected is usually the "prove you're human" check.
if (makerworld.isChallengeText(err.raw)) {
problems.push({ message: downloadBlock.DEFAULT, challenged: true });
downloadBlock.block('makerworld');
break;
}
problems.push({ message: `Couldn’t ask MakerWorld for a download link: ${err.message}` });
continue;
}
const problem = makerworld.downloadProblem(dl);
if (problem) {
console.warn(`MakerWorld instance ${inst.id}: ${problem.message}`);
problems.push(problem);
// Only you can clear this one, so stop rather than collect refusals.
if (problem.challenged) { downloadBlock.block('makerworld', problem.message); break; }
continue; continue;
} }
if (!dl || !dl.url) continue;
const original = dl.name || `instance-${inst.id}.3mf`; const original = dl.name || `instance-${inst.id}.3mf`;
if (skipNames.has(original)) continue; if (skipNames.has(original)) continue;
const resp = await fetch(dl.url); // A stalled CDN connection would otherwise hang the download for ever.
if (!resp.ok) { console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); continue; } const resp = await fetch(dl.url, { signal: AbortSignal.timeout(180000) });
if (!resp.ok) {
console.warn(`MakerWorld CDN download ${resp.status} for ${original}`);
problems.push({ message: `Downloading ${original} from MakerWorld failed (HTTP ${resp.status})` });
continue;
}
const fileName = uniqueName(original); const fileName = uniqueName(original);
const filePath = path.join(UPLOAD_DIR, fileName); const filePath = path.join(UPLOAD_DIR, fileName);
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer())); fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
files.push(describeFile(filePath, fileName, original)); files.push(describeFile(filePath, fileName, original));
downloadBlock.clear('makerworld'); // downloads are going through again
}
if (!files.length && problems.length) {
const refused = problems.find((p) => p.tokenRejected);
const challenged = problems.find((p) => p.challenged);
const worst = refused || challenged || problems[0];
throw Object.assign(new Error(worst.message),
{ status: 502, reason: refused ? 'makerworld_token' : challenged ? 'makerworld_challenge' : undefined });
} }
return files; return files;
} }
@ -933,7 +1266,8 @@ async function downloadThingiverseFiles(meta, skipNames = new Set()) {
for (const dl of meta.downloads) { for (const dl of meta.downloads) {
if (!fileProcessor.isAllowed(dl.name)) continue; // skip readmes/images if (!fileProcessor.isAllowed(dl.name)) continue; // skip readmes/images
if (skipNames.has(dl.name)) continue; if (skipNames.has(dl.name)) continue;
const resp = await fetch(dl.url); // A stalled CDN connection would otherwise hang the download for ever.
const resp = await fetch(dl.url, { signal: AbortSignal.timeout(180000) });
if (!resp.ok) { console.error(`Download failed (${resp.status}): ${dl.name}`); continue; } if (!resp.ok) { console.error(`Download failed (${resp.status}): ${dl.name}`); continue; }
const buffer = Buffer.from(await resp.arrayBuffer()); const buffer = Buffer.from(await resp.arrayBuffer());
const fileName = uniqueName(dl.name); const fileName = uniqueName(dl.name);
@ -1009,9 +1343,12 @@ async function findDuplicateGroups() {
app.get('/api/library/stats', async (req, res, next) => { app.get('/api/library/stats', async (req, res, next) => {
try { try {
const [trash, groups, pending, thumbnailsPending] = await Promise.all([ const [trash, groups, pending, thumbnailsPending, tagsPending, aiRunning] = await Promise.all([
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount()]); store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount(),
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending }); autotag.pendingCount(), aiRuns.hasActiveRun()]);
const { rows: [watches] } = await db.query('SELECT COUNT(*)::int AS n FROM watched_collections WHERE enabled');
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending, tagsPending,
aiRunning, watches: watches.n });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -1071,15 +1408,307 @@ function parseJson(value, fallback) {
try { return JSON.parse(value); } catch { return fallback; } try { return JSON.parse(value); } catch { return fallback; }
} }
// ----------------------------------------------------------------- AI tagging
// Which models a run covers: not yet tagged by AI, everything, or a chosen list.
async function aiScopeIds(scope, ids) {
if (scope === 'ids') return (Array.isArray(ids) ? ids : []).map(Number).filter(Number.isInteger);
const { rows } = await db.query(
`SELECT id FROM models WHERE deleted_at IS NULL ${scope === 'all' ? '' : 'AND ai_tagged_at IS NULL'} ORDER BY id`);
return rows.map((r) => r.id);
}
app.get('/api/ai/status', async (req, res, next) => {
try {
const config = await resolveAiConfig();
const { rows: [counts] } = await db.query(
`SELECT COUNT(*)::int AS all, COUNT(*) FILTER (WHERE ai_tagged_at IS NULL)::int AS untagged
FROM models WHERE deleted_at IS NULL`);
const { apiKey, ...safe } = config;
res.json({
config: { ...safe, keySet: Boolean(apiKey) },
problem: ai.missingConfig(config),
providers: ai.PROVIDERS,
counts,
search: await embeddings.status(),
autotag: { enabled: await autotag.isEnabled(), pending: await autotag.pendingCount() },
});
} catch (e) { next(e); }
});
// Tags a sample model with the saved settings, sending a library thumbnail too
// when images are on, to show whether the key, model and image input all work.
app.post('/api/ai/test', async (req, res, next) => {
try {
const config = await resolveAiConfig();
const problem = ai.missingConfig(config);
if (problem) return res.status(400).json({ error: problem });
let image = null;
if (config.sendImages) {
const { rows } = await db.query(
`SELECT id, thumbnail_path FROM models WHERE thumbnail_path IS NOT NULL AND deleted_at IS NULL ORDER BY id DESC LIMIT 10`);
image = rows.map((m) => aiRuns.thumbnailDataUrl(m)).find(Boolean) || null;
}
const started = Date.now();
const sample = { name: 'Flexi articulated dragon', files: [{ originalName: 'flexi_dragon_print_in_place.3mf' }], tags: [] };
try {
const result = await ai.describeModel(config, sample, { image });
res.json({
ok: true, model: config.model, ms: Date.now() - started, tags: result.tags, description: result.description,
images: !image ? 'not tried' : result.imageUsed ? 'accepted' : 'not supported by this model',
usage: result.usage, cost: result.cost,
});
} catch (err) {
res.status(502).json({ error: ai.fatalMessage(err) || err.message });
}
} catch (e) { next(e); }
});
app.post('/api/ai/estimate', async (req, res, next) => {
try {
const ids = await aiScopeIds(req.body && req.body.scope, req.body && req.body.ids);
res.json({ count: ids.length, ...ai.estimate(ids.length, await resolveAiConfig()) });
} catch (e) { next(e); }
});
app.get('/api/ai/runs', async (req, res, next) => {
try { res.json(await aiRuns.listRuns()); } catch (e) { next(e); }
});
app.post('/api/ai/runs', async (req, res, next) => {
try {
const { scope, ids, describe, overwriteDescriptions, renameUnclear, suggestCollections, maxCost } = req.body || {};
const run = await aiRuns.createRun(await aiScopeIds(scope, ids),
{ describe, overwriteDescriptions, renameUnclear, suggestCollections, maxCost });
res.status(201).json(run);
} catch (e) { next(e); }
});
app.get('/api/ai/runs/:id', async (req, res, next) => {
try {
const run = await aiRuns.getRun(Number(req.params.id));
if (!run) return res.status(404).json({ error: 'Run not found' });
res.json(run);
} catch (e) { next(e); }
});
app.post('/api/ai/runs/:id/stop', async (req, res, next) => {
try {
if (!(await aiRuns.stop(Number(req.params.id)))) return res.status(409).json({ error: 'This run isn’t going' });
res.json(await aiRuns.getRun(Number(req.params.id)));
} catch (e) { next(e); }
});
app.post('/api/ai/runs/:id/resume', async (req, res, next) => {
try {
if (!(await aiRuns.resume(Number(req.params.id)))) return res.status(409).json({ error: 'Nothing left to do in this run' });
res.json(await aiRuns.getRun(Number(req.params.id)));
} catch (e) { next(e); }
});
// ----------------------------------------------------------------- watched collections
// Reads a MakerWorld collection page as you: a private collection is only listed
// when the request carries your token.
const COLLECTION_PAGE_SIZE = 64; // the most that call returns at once
const COLLECTION_MAX_PAGES = 10; // so one check reads at most ~640 models
async function fetchMakerworldCollection(url) {
const { token, flareUrl } = await requireMakerworldSettings();
const cookies = [{ name: 'token', value: token, domain: '.makerworld.com' }];
const html = await flaresolverr.solve(flareUrl, url, { cookies });
const collection = makerworld.parseCollection(html);
if (!collection) {
throw Object.assign(
new Error('MakerWorld didn’t show that collection. If it’s private, your MakerWorld token may have expired; otherwise check the link.'),
{ status: 502, reason: 'makerworld_token' });
}
// The page itself lists only the first models; the rest come from the same call
// its "load more" uses. A shared seed keeps the pages from overlapping.
const byId = new Map(collection.designs.map((d) => [d.id, d]));
let seed;
for (let page = 0; page < COLLECTION_MAX_PAGES && byId.size < collection.total; page++) {
const before = byId.size;
let more;
try {
const json = await flaresolverr.solveJson(flareUrl,
makerworld.collectionDesignsUrl(collection.id, { offset: page * COLLECTION_PAGE_SIZE, limit: COLLECTION_PAGE_SIZE, seed }),
{ cookies, retries: 1 });
more = makerworld.parseCollectionDesigns(json);
} catch (err) {
console.warn(`MakerWorld collection ${collection.id}: couldn't read more models:`, err.message);
break;
}
if (seed === undefined) seed = more.seed;
for (const design of more.designs) if (!byId.has(design.id)) byId.set(design.id, design);
if (!more.designs.length || byId.size === before) break; // nothing new: stop asking
}
return { ...collection, designs: [...byId.values()] };
}
// Whether a site is waiting for you to prove you're not a robot, and the
// button that says you have. Clearing it only lets Printhive try again.
app.get('/api/downloads/block', (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(downloadBlock.state('makerworld'));
});
app.post('/api/downloads/block/clear', (req, res) => {
downloadBlock.clear('makerworld');
res.json({ message: 'Printhive will try MakerWorld downloads again.' });
});
app.get('/api/watches', async (req, res, next) => {
try { res.json(await collectionWatch.list()); } catch (e) { next(e); }
});
app.get('/api/watches/:id', async (req, res, next) => {
try {
const watch = await collectionWatch.get(Number(req.params.id));
if (!watch) return res.status(404).json({ error: 'Not watching that collection' });
res.json(watch);
} catch (e) { next(e); }
});
app.post('/api/watches', async (req, res, next) => {
try {
const { url, aiTag, intervalHours, fetchFiles } = req.body || {};
res.status(201).json(await collectionWatch.add({ url: String(url || '').trim(), aiTag, intervalHours, fetchFiles }));
} catch (e) { next(e); }
});
app.put('/api/watches/:id', async (req, res, next) => {
try {
const watch = await collectionWatch.update(Number(req.params.id), req.body || {});
if (!watch) return res.status(404).json({ error: 'Not watching that collection' });
res.json(watch);
} catch (e) { next(e); }
});
app.delete('/api/watches/:id', async (req, res, next) => {
try {
if (!(await collectionWatch.remove(Number(req.params.id)))) return res.status(404).json({ error: 'Not watching that collection' });
res.json({ message: 'Stopped watching. The models it imported are kept.' });
} catch (e) { next(e); }
});
// Reads the collection now (so problems come back here) and imports in the background.
app.post('/api/watches/:id/check', async (req, res, next) => {
try { res.json(await collectionWatch.checkNow(Number(req.params.id))); } catch (e) { next(e); }
});
// ----------------------------------------------------------------- collection suggestions
function suggestionTarget(body) {
const name = typeof (body && body.name) === 'string' ? body.name.trim() : '';
const modelIds = Array.isArray(body && body.modelIds) ? body.modelIds.map(Number).filter(Number.isInteger) : null;
return { name, modelIds: modelIds && modelIds.length ? modelIds : null };
}
app.get('/api/collection-suggestions', async (req, res, next) => {
try { res.json(await store.listCollectionSuggestions()); } catch (e) { next(e); }
});
app.post('/api/collection-suggestions/accept', async (req, res, next) => {
try {
const { name, modelIds } = suggestionTarget(req.body);
if (!name) return res.status(400).json({ error: 'Which suggestion?' });
const result = await store.acceptCollectionSuggestion(name, modelIds);
if (!result) return res.status(404).json({ error: 'That suggestion is no longer waiting' });
res.json(result);
} catch (e) { next(e); }
});
app.post('/api/collection-suggestions/dismiss', async (req, res, next) => {
try {
const { name, modelIds } = suggestionTarget(req.body);
if (!name) return res.status(400).json({ error: 'Which suggestion?' });
res.json({ dismissed: await store.dismissCollectionSuggestion(name, modelIds) });
} catch (e) { next(e); }
});
// ----------------------------------------------------------------- semantic search
app.get('/api/search', async (req, res, next) => {
try {
const q = String(req.query.q || '').trim();
if (!q) return res.json({ results: [] });
try {
res.json({ results: await embeddings.search(q) });
} catch (err) {
res.status(503).json({ error: `Search by meaning isn’t available: ${err.message}` });
}
} catch (e) { next(e); }
});
// ----------------------------------------------------------------- automatic tags
async function autotagState() {
return {
enabled: await autotag.isEnabled(),
pending: await autotag.pendingCount(),
builtIn: autotag.BUILT_IN_RULES,
rules: await autotag.listRules(),
};
}
app.get('/api/autotag', async (req, res, next) => {
try { res.json(await autotagState()); } catch (e) { next(e); }
});
app.put('/api/autotag', async (req, res, next) => {
try {
const { enabled, removeTags } = req.body || {};
const removed = await autotag.setEnabled(Boolean(enabled), { removeTags: Boolean(removeTags) });
fingerprint.kick();
res.json({ ...(await autotagState()), removed });
} catch (e) { next(e); }
});
app.post('/api/autotag/rules', async (req, res, next) => {
try {
await autotag.addRule(req.body && req.body.phrase, req.body && req.body.tag);
fingerprint.kick();
res.status(201).json(await autotagState());
} catch (e) { next(e); }
});
app.delete('/api/autotag/rules/:id', async (req, res, next) => {
try {
await autotag.removeRule(Number(req.params.id));
fingerprint.kick();
res.json(await autotagState());
} catch (e) { next(e); }
});
app.post('/api/autotag/reapply', async (req, res, next) => {
try {
await autotag.reapply();
fingerprint.kick();
res.json(await autotagState());
} catch (e) { next(e); }
});
// Central error handler. // Central error handler.
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
if (!err.status) console.error('Request error:', err.message); if (!err.status) console.error('Request error:', err.message);
res.status(err.status || 500).json({ error: err.message }); res.status(err.status || 500).json({ error: err.message, ...(err.reason ? { reason: err.reason } : {}) });
}); });
db.init() db.init()
.then(() => { .then(() => {
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)); app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
fingerprint.kick(); // backfill fingerprints for files stored before this ran fingerprint.kick(); // backfill fingerprints, tags and the search index
collectionWatch.init({
fetchCollection: fetchMakerworldCollection,
importFromUrl,
// Same treatment a manual run gives: tags and a description, a clearer name
// where the site's one is boilerplate, and collections to review.
startAiRun: (modelIds) => aiRuns.createRun(modelIds,
{ describe: true, renameUnclear: true, suggestCollections: true }),
});
collectionWatch.start();
// Live connections to the printers keep status and spools current. One
// that's off or unreachable keeps retrying quietly.
reconnectPrinters().catch((err) => console.warn('Printer connections failed:', err.message));
return aiRuns.init(resolveAiConfig);
}) })
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); }); .catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });

218
test/ai.test.js Normal file
View file

@ -0,0 +1,218 @@
// AI tagging client (lib/ai.js), against a fake OpenAI-compatible server.
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const ai = require('../lib/ai');
// Starts a server that answers each request with the next handler's result.
async function fakeProvider(handlers) {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const parsed = JSON.parse(body);
requests.push({ path: req.url, auth: req.headers.authorization, body: parsed });
const [status, payload] = handlers[Math.min(requests.length, handlers.length) - 1](parsed);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const config = ai.configFrom({
ai_provider: 'custom', ai_base_url: `http://127.0.0.1:${server.address().port}/v1/`, ai_api_key: 'sk-test',
ai_model: 'test-model', ai_price_input: '0.3', ai_price_output: '1.2',
});
return { config, requests, close: () => new Promise((resolve) => server.close(resolve)) };
}
const reply = (content, usage = { prompt_tokens: 1000, completion_tokens: 100 }) =>
[200, { choices: [{ message: { content } }], usage }];
const MODEL = { name: 'Flexi Dragon', files: [{ originalName: 'dragon_PLA.3mf', bbox: { x: 120, y: 40, z: 30 } }], tags: ['toy'] };
test('presets fill in DeepSeek by default, and your own values win', () => {
const deepseek = ai.configFrom({ ai_api_key: 'k' });
assert.equal(deepseek.provider, 'deepseek');
assert.equal(deepseek.baseUrl, 'https://api.deepseek.com');
assert.equal(deepseek.model, 'deepseek-flash');
assert.equal(ai.missingConfig(deepseek), null);
assert.match(ai.missingConfig(ai.configFrom({})), /API key/);
const openrouter = ai.configFrom({ ai_provider: 'openrouter', ai_api_key: 'k' });
assert.match(ai.missingConfig(openrouter), /model/);
assert.equal(openrouter.prices.input, null);
// A local server needs no key.
assert.equal(ai.missingConfig(ai.configFrom({ ai_provider: 'custom', ai_base_url: 'http://localhost:11434/v1', ai_model: 'llava' })), null);
});
test('the prompt carries the model details and, when given, its image', () => {
const [system, user] = ai.buildMessages(MODEL, { image: 'data:image/png;base64,AAAA', knownTags: ['dragon', 'toy'] });
assert.equal(system.role, 'system');
const text = user.content[0].text;
assert.match(text, /Name: Flexi Dragon/);
assert.match(text, /Files: dragon_PLA\.3mf/);
assert.match(text, /Size: 120 × 40 × 30 mm/);
assert.match(text, /Tags already used in this library: dragon, toy/);
assert.deepEqual(user.content[1], { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } });
assert.equal(typeof ai.buildMessages(MODEL)[1].content, 'string');
});
test('replies are read leniently and tags tidied', () => {
const r = ai.parseReply('Sure!\n```json\n{"tags": ["#Dragon", "3D print", "Print_In Place", "dragon", "a", "b", "c", "d", "e", "f"], "description": " A flexible dragon toy. "}\n```');
assert.deepEqual(r.tags, ['dragon', 'print in place', 'a', 'b', 'c', 'd', 'e', 'f']);
assert.equal(r.description, 'A flexible dragon toy.');
assert.deepEqual(ai.parseReply('{"tags": "hook, wall mount", "description": ""}').tags, ['hook', 'wall mount']);
assert.throws(() => ai.parseReply('I cannot help with that'), /JSON/);
assert.throws(() => ai.parseReply('{"tags": [], "description": ""}'), /no tags/);
});
test('a clearer name is suggested only when asked, and only when it changes', () => {
assert.match(ai.buildMessages(MODEL, { suggestName: true })[0].content, /"name"/);
assert.doesNotMatch(ai.buildMessages(MODEL)[0].content, /"name"/);
const reply = '{"name": "“Articulated Dragon Pair.3mf”", "tags": ["dragon"], "description": "Two dragons."}';
assert.equal(ai.parseReply(reply, { currentName: 'Dra g _v4' }).name, 'Articulated Dragon Pair');
assert.equal(ai.parseReply('{"name": "flexi dragon!", "tags": ["dragon"]}', { currentName: 'Flexi Dragon' }).name, null);
assert.equal(ai.parseReply(reply).name, null); // not asked for
});
test('cost comes from the provider when reported, else from your prices', () => {
assert.equal(ai.costOf({ prompt_tokens: 1e6, completion_tokens: 1e6, cost: 0.0123 }, { input: 1, output: 1 }), 0.0123);
assert.equal(ai.costOf({ prompt_tokens: 2e6, completion_tokens: 1e6 }, { input: 0.3, output: 1.2 }), 1.8);
assert.equal(ai.costOf({ prompt_tokens: 10, completion_tokens: 10 }, { input: null, output: null }), null);
});
test('a model that refuses JSON mode and images is retried without them', async () => {
const provider = await fakeProvider([
() => [400, { error: { message: 'response_format is not supported by this model' } }],
() => [400, { error: { message: 'This model does not support image input' } }],
() => reply('{"tags": ["dragon", "articulated"], "description": "A flexi dragon."}'),
]);
try {
const result = await ai.describeModel(provider.config, MODEL, { image: 'data:image/png;base64,AAAA' });
assert.deepEqual(result.tags, ['dragon', 'articulated']);
assert.equal(result.imageRejected, true);
assert.equal(result.imageUsed, false);
assert.deepEqual(result.usage, { prompt: 1000, completion: 100 });
assert.ok(Math.abs(result.cost - 0.00042) < 1e-9);
assert.equal(provider.requests.length, 3);
assert.equal(provider.requests[0].path, '/v1/chat/completions');
assert.equal(provider.requests[0].auth, 'Bearer sk-test');
assert.ok(provider.requests[0].body.response_format);
assert.equal(provider.requests[1].body.response_format, undefined);
assert.equal(typeof provider.requests[2].body.messages[1].content, 'string'); // no image part
} finally {
await provider.close();
}
});
test('collection ideas are asked for with the library’s collections, and tidied', () => {
const [system, user] = ai.buildMessages(MODEL, { describe: false, suggestCollections: true, collectionNames: ['Bookends', 'Halloween'] });
assert.match(system.content, /"collections"/);
assert.doesNotMatch(system.content, /"tags"/);
assert.match(user.content, /Existing collections in this library: Bookends, Halloween/);
assert.match(ai.buildMessages(MODEL, { suggestCollections: true })[1].content, /no collections yet/);
const reply = '{"collections": ["bookends", " Halloween Decor ", "Book-ends", "Dragons", "Toys"]}';
const r = ai.parseReply(reply, { describe: false, collections: true });
assert.deepEqual(r.collections, ['bookends', 'Halloween Decor', 'Dragons']);
assert.deepEqual(r.tags, []); // not asked for, so an answer without tags is fine
assert.deepEqual(ai.parseReply('{"tags": ["dragon"], "collections": ["Dragons"]}').collections, []);
});
test('a reply that isn’t JSON is asked again, and every answer counts towards cost', async () => {
const provider = await fakeProvider([
() => reply('Sure! Tags: dragon, toy'),
() => reply('{"tags": ["dragon"], "description": "A dragon."}'),
]);
try {
const result = await ai.describeModel(provider.config, MODEL);
assert.deepEqual(result.tags, ['dragon']);
assert.equal(result.replyRetried, true);
assert.deepEqual(result.usage, { prompt: 2000, completion: 200 });
assert.ok(Math.abs(result.cost - 0.00084) < 1e-9);
assert.equal(provider.requests.length, 2);
} finally {
await provider.close();
}
// Empty replies in JSON mode: the last try goes without JSON mode.
const empty = await fakeProvider([() => reply(''), () => reply(''), () => reply('{"tags": ["dragon"], "description": "A dragon."}')]);
try {
const result = await ai.describeModel(empty.config, MODEL);
assert.deepEqual(result.tags, ['dragon']);
assert.ok(empty.requests[1].body.response_format);
assert.equal(empty.requests[2].body.response_format, undefined);
} finally {
await empty.close();
}
// Still nothing usable: the error says what came back and carries the spend.
const stubborn = await fakeProvider([() => reply('')]);
try {
const err = await ai.describeModel(stubborn.config, MODEL).catch((e) => e);
assert.match(err.message, /reply was empty, not the JSON asked for/);
assert.equal(stubborn.requests.length, 3);
assert.deepEqual(err.usage, { prompt: 3000, completion: 300 });
} finally {
await stubborn.close();
}
});
test('a reply that was cut off is asked again with more room', async () => {
const cutOff = [200, { choices: [{ message: { content: '{"tags": ["red panda", "fl' }, finish_reason: 'length' }], usage: { prompt_tokens: 1000, completion_tokens: 100 } }];
const provider = await fakeProvider([() => cutOff, () => reply('{"tags": ["red panda"], "description": "A flexi red panda."}')]);
try {
const result = await ai.describeModel(provider.config, MODEL);
assert.deepEqual(result.tags, ['red panda']);
assert.equal(provider.requests[0].body.max_tokens, 1500);
assert.equal(provider.requests[1].body.max_tokens, 3000);
} finally {
await provider.close();
}
assert.throws(() => ai.parseReply('{"tags": ["red', { finishReason: 'length' }),
/cut off\)\. It began: “\{"tags": \["red”/);
});
test('JSON is found inside fences, notes and braces after it', () => {
assert.deepEqual(ai.parseReply('```json\n{"tags": ["owl"], "description": "An owl {bookend}."}\n```').tags, ['owl']);
assert.equal(ai.parseReply('Here you go: {"tags": ["owl"], "description": "An owl."} Note: fields were {tags, description}.').description, 'An owl.');
assert.throws(() => ai.parseReply('I cannot help with that'), /It began: “I cannot help with that”/);
});
test('the prompt shows the reply format for just the fields asked for', () => {
const plain = ai.buildMessages(MODEL)[0].content;
assert.match(plain, /The format, shown for a different model: \{"tags":\["wall hook","headphone holder"\],"description":"A wall hook for hanging headphones\."\}/);
const collectionsOnly = ai.buildMessages(MODEL, { describe: false, suggestCollections: true })[0].content;
assert.match(collectionsOnly, /\{"collections":\["Desk Organizers"\]\}/);
});
test('text sent to the AI never holds half an emoji', () => {
// The panda starts at unit 799, so a plain cut at 800 would split it.
const description = `${'a'.repeat(799)}🐼 red panda`;
const [, user] = ai.buildMessages({ name: 'Red panda', files: [], tags: [], description });
assert.ok(user.content.isWellFormed());
assert.ok(!user.content.includes('\uFFFD'), 'clipped before the emoji, not replaced');
assert.match(user.content, /Description so far: a{799}$/m);
// Text that's already broken in the library is made safe before it's sent.
const [, broken] = ai.buildMessages({ name: 'Bad \uD83D name', files: [], tags: ['ok'], description: '' }, { knownTags: ['tag\uDC3C'] });
assert.ok(broken.content.isWellFormed());
const reply = JSON.stringify({ name: `x${'🐼'.repeat(50)}`, tags: ['panda'], description: `x${'🐼'.repeat(400)}`, collections: [`x${'🐼'.repeat(40)}`] });
const parsed = ai.parseReply(reply, { currentName: 'Red panda', collections: true });
assert.ok(parsed.name.isWellFormed() && parsed.description.isWellFormed() && parsed.collections[0].isWellFormed());
});
test('a rejected key is fatal for a run; a bad reply is not', async () => {
const provider = await fakeProvider([() => [401, { error: { message: 'Authentication Fails' } }]]);
try {
const err = await ai.describeModel(provider.config, MODEL).catch((e) => e);
assert.match(ai.fatalMessage(err), /API key was rejected/);
} finally {
await provider.close();
}
assert.equal(ai.fatalMessage(new Error('The AI reply wasn’t the JSON asked for')), null);
assert.match(ai.fatalMessage(new ai.AiError('nope', 402)), /out of credit/);
});

33
test/autotag.test.js Normal file
View file

@ -0,0 +1,33 @@
// Automatic tagging rules (lib/autotag.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const { tokenize, matchTags } = require('../lib/autotag');
test('names split into words at punctuation, camelCase and numbers', () => {
assert.deepEqual(tokenize('PETGWallHook_v2.stl'), ['petg', 'wall', 'hook', 'v', '2', 'stl']);
assert.deepEqual(tokenize('Gridfinity-6x4 baseplate'), ['gridfinity', '6', 'x', '4', 'baseplate']);
});
test('rules match whole words only, plurals included', () => {
assert.deepEqual(matchTags(['Wall Hooks.stl']), ['hook']);
assert.deepEqual(matchTags(['facebook_logo.stl', 'blocked-path.3mf', 'planter.stl', 'absolute.obj']), []);
assert.deepEqual(matchTags(['gears_and_threads.3mf']).sort(), ['gear', 'threaded']);
});
test('phrases match across separators but not across different names', () => {
assert.deepEqual(matchTags(['Print-In-Place_Dragon.3mf']), ['print-in-place']);
assert.deepEqual(matchTags(['Dragon Print', 'in place']), []);
assert.deepEqual(matchTags(['Spiral Vase Mode lamp']), ['vase mode']);
});
test('materials and techniques are picked up', () => {
assert.deepEqual(matchTags(['benchy_PLA.3mf', 'TPU tyre']).sort(), ['pla', 'tpu']);
assert.deepEqual(matchTags(['MultiColour Keyring']).sort(), ['keychain', 'multicolor']);
});
test('your own rules add tags the same strict way', () => {
const rules = [{ phrase: 'benchy', tag: 'Calibration' }, { phrase: 'cable tie', tag: 'cable management' }];
assert.deepEqual(matchTags(['3DBenchy.stl'], rules), ['calibration']);
assert.deepEqual(matchTags(['cable_ties_holder.stl'], rules).sort(), ['cable management', 'holder']);
assert.deepEqual(matchTags(['benchymark.stl'], rules), []);
});

View file

@ -0,0 +1,34 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const block = require('../lib/downloadBlock');
const makerworld = require('../lib/importers/makerworld');
test('a blocked site stays blocked until it’s cleared', () => {
assert.equal(block.isBlocked('makerworld'), false);
block.block('makerworld', 'Prove you’re not a robot.');
assert.equal(block.isBlocked('makerworld'), true);
const state = block.state('makerworld');
assert.equal(state.blocked, true);
assert.equal(state.message, 'Prove you’re not a robot.');
assert.ok(state.since instanceof Date);
// Blocking again keeps the original message and time.
const again = block.block('makerworld', 'something else');
assert.equal(again.message, 'Prove you’re not a robot.');
block.clear('makerworld');
assert.deepEqual(block.state('makerworld'), { blocked: false });
});
test('MakerWorld’s anti-bot answers are recognised, ordinary refusals aren’t', () => {
for (const text of ['Please verify you are not a robot', 'Just a moment…', 'Select in this order',
'Security check required', 'Verification required']) {
assert.equal(makerworld.isChallengeText(text), true, text);
}
for (const text of ['Please log in to download models.', 'Model not found', '']) {
assert.equal(makerworld.isChallengeText(text), false, text);
}
// An answer carrying a link is never a challenge.
assert.equal(makerworld.isChallenge({ url: 'https://cdn/x.3mf', message: 'captcha' }), false);
const problem = makerworld.downloadProblem({ error: 'Please verify you are not a robot' });
assert.equal(problem.challenged, true);
assert.equal(makerworld.downloadProblem({ error: 'Please log in to download models.' }).tokenRejected, true);
});

148
test/filaments.test.js Normal file
View file

@ -0,0 +1,148 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const filaments = require('../lib/filaments');
const spool = (material, hex, name) => ({ id: hex, material, colour_hex: hex, colour: name || hex, remaining_g: 500 });
// The model that started this: four PLA slots, one spool of Jade White.
const pooh = [
{ type: 'PLA', colour: '#000000' },
{ type: 'PLA', colour: '#586579' },
{ type: 'PLA', colour: '#8E9089' },
{ type: 'PLA', colour: '#FFFFFF' },
];
test('a four-colour print needs those colours, not four spools of anything', () => {
const shelf = [spool('PLA', '#ffffff', 'Jade White')];
const slots = filaments.assignSpools(pooh, shelf);
assert.deepEqual(slots.map((s) => s.filled), [false, false, false, true]);
assert.equal(slots[3].by, 'Jade White');
assert.equal(filaments.isPrintable(pooh, shelf), false);
assert.equal(filaments.shortfall(pooh, shelf).length, 3);
// Four white spools still can't print it: the other three colours are missing.
const fourWhite = ['a', 'b', 'c', 'd'].map((k) => ({ ...spool('PLA', '#ffffff', 'Jade White'), id: k }));
assert.equal(filaments.isPrintable(pooh, fourWhite), false);
});
test('with every colour on the shelf it prints', () => {
const shelf = [spool('PLA', '#000000', 'Black'), spool('PLA', '#586579', 'Blue Grey'),
spool('PLA', '#8E9089', 'Grey'), spool('PLA', '#ffffff', 'Jade White')];
assert.equal(filaments.isPrintable(pooh, shelf), true);
assert.deepEqual(filaments.assignSpools(pooh, shelf).map((s) => s.by),
['Black', 'Blue Grey', 'Grey', 'Jade White']);
});
test('one spool cannot fill two slots of the same colour', () => {
const twoBlack = [{ type: 'PLA', colour: '#000000' }, { type: 'PLA', colour: '#000000' }];
assert.equal(filaments.isPrintable(twoBlack, [spool('PLA', '#000000', 'Black')]), false);
assert.equal(filaments.isPrintable(twoBlack,
[spool('PLA', '#000000', 'Black'), { ...spool('PLA', '#000000', 'Black 2'), id: 'b2' }]), true);
});
test('8-digit hex with alpha parses as its colour', () => {
assert.deepEqual(filaments.rgb('#FFFFFFFF'), { r: 255, g: 255, b: 255 });
assert.deepEqual(filaments.rgb('#586579'), { r: 88, g: 101, b: 121 });
assert.equal(filaments.rgb('not a colour'), null);
// A slot written with alpha still matches a plain spool of that colour.
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#FFFFFFFF' }],
[spool('PLA', '#ffffff', 'Jade White')]), true);
});
test('near shades count, but near-black is not black', () => {
const nearWhite = [{ type: 'PLA', colour: '#FDFDFD' }];
assert.equal(filaments.isPrintable(nearWhite, [spool('PLA', '#ffffff', 'Jade White')]), true);
// #161616 sits about 66 away from black, beyond the tolerance of 50.
assert.ok(filaments.colourDistance('#000000', '#161616') > filaments.COLOUR_TOLERANCE);
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#161616' }],
[spool('PLA', '#000000', 'Black')]), false);
});
test('the nearest spool is named when a slot can’t be filled', () => {
const [slot] = filaments.assignSpools([{ type: 'PLA', colour: '#000000' }],
[spool('PLA', '#ffffff', 'Jade White')]);
assert.equal(slot.filled, false);
assert.equal(slot.nearest.name, 'Jade White');
assert.ok(slot.nearest.distance > 0);
});
test('material still matters, and a slot with no colour takes any spool of it', () => {
assert.equal(filaments.isPrintable([{ type: 'PETG', colour: '#ffffff' }],
[spool('PLA', '#ffffff', 'Jade White')]), false);
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }],
[spool('PLA', '#123456', 'Anything')]), true);
});
test('an empty spool doesn’t count, but an unweighed one does', () => {
const one = [{ type: 'PLA', colour: '#ffffff' }];
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: 0 }]), false);
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: null }]), true);
});
test('a model we know nothing about is not claimed to be printable', () => {
assert.equal(filaments.isPrintable(null, [spool('PLA', '#ffffff')]), false);
assert.equal(filaments.isPrintable([], [spool('PLA', '#ffffff')]), false);
assert.deepEqual(filaments.assignSpools(null, []), []);
});
test('slots sent from the page are cleaned before they reach the database', () => {
const cleaned = filaments.cleanSlots([
{ type: ' pla ', colour: '#ffffff' }, // trimmed, hex upper-cased
{ type: 'PETG', colour: 'not a colour' }, // bad colour becomes "any colour"
{ type: '', colour: '#000000' }, // no material: dropped entirely
{ type: 'PLA', colour: '#8e9089' },
]);
assert.deepEqual(cleaned, [
{ type: 'pla', colour: '#FFFFFF' },
{ type: 'PETG', colour: null },
{ type: 'PLA', colour: '#8E9089' },
]);
});
test('anything that isn’t a list of slots is refused outright', () => {
assert.equal(filaments.cleanSlots(null), null);
assert.equal(filaments.cleanSlots('PLA'), null);
assert.equal(filaments.cleanSlots({ type: 'PLA' }), null);
assert.deepEqual(filaments.cleanSlots([]), []);
});
test('"any colour" survives the trip from the page to the database', () => {
// What the editor sends when the box is ticked, plus the two shapes that can
// arrive from older clients or a hand-made request.
assert.deepEqual(filaments.cleanSlots([
{ type: 'PLA', colour: null },
{ type: 'PETG', colour: '' },
{ type: 'ABS' },
]), [
{ type: 'PLA', colour: null },
{ type: 'PETG', colour: null },
{ type: 'ABS', colour: null },
]);
});
test('a slot with no colour is filled by any spool of that material', () => {
const anyWhite = [{ type: 'PLA', colour: null }];
assert.equal(filaments.isPrintable(anyWhite,
[{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), true);
// …but still not by the wrong material, and still one spool per slot.
assert.equal(filaments.isPrintable(anyWhite,
[{ id: 1, material: 'PETG', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false);
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }, { type: 'PLA', colour: null }],
[{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false);
});
test('an "any colour" slot takes what’s left, not what another slot needs', () => {
// Reported from the live library: editing slot 2 of a bookmark to "any
// colour" stole the one white spool, leaving the slot that actually wanted
// #FFFFFF empty and the verdict no better than before.
const shelf = [{ id: 1, material: 'PLA', colour_hex: '#FFFFFF', colour: 'Jade White', remaining_g: 1000 }];
const slots = filaments.assignSpools([{ type: 'PLA', colour: null }, { type: 'PLA', colour: '#FFFFFF' }], shelf);
assert.equal(slots[1].filled, true, 'the slot that asks for white gets the white spool');
assert.equal(slots[1].by, 'Jade White');
assert.equal(slots[0].filled, false, 'the any-colour slot goes without');
// With two spools, both are filled and the exact match still gets white.
const two = shelf.concat({ id: 2, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 500 });
const both = filaments.assignSpools([{ type: 'PLA', colour: null }, { type: 'PLA', colour: '#FFFFFF' }], two);
assert.deepEqual(both.map((s) => s.by), ['Anything', 'Jade White']);
});

42
test/handoff.test.js Normal file
View file

@ -0,0 +1,42 @@
// Short-lived "Open in slicer" download links (lib/handoff.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const jwt = require('jsonwebtoken');
const handoff = require('../lib/handoff');
const SECRET = 'a'.repeat(96);
test('a link grants the file it was made for', () => {
const token = handoff.createToken(SECRET, { kind: 'entry', id: '42' });
assert.deepEqual(handoff.readToken(SECRET, token), { kind: 'entry', id: 42 });
});
test('expired, altered or foreign links grant nothing', () => {
const expired = handoff.createToken(SECRET, { kind: 'file', id: 1 }, -5);
assert.equal(handoff.readToken(SECRET, expired), null);
const token = handoff.createToken(SECRET, { kind: 'file', id: 1 });
const [head, , sig] = token.split('.');
const otherBody = Buffer.from(JSON.stringify({ k: 'file', id: 2, aud: 'printhive-handoff', exp: 9999999999 })).toString('base64url');
assert.equal(handoff.readToken(SECRET, `${head}.${otherBody}.${sig}`), null);
assert.equal(handoff.readToken('b'.repeat(96), token), null);
assert.equal(handoff.readToken(SECRET, 'not-a-token'), null);
});
test('a login token is not a hand-off link', () => {
const session = jwt.sign({ sub: 1, email: 'x@example.com' }, SECRET, { expiresIn: '30d' });
assert.equal(handoff.readToken(SECRET, session), null);
// …and a hand-off link doesn't verify as a login.
assert.throws(() => jwt.verify(handoff.createToken(SECRET, { kind: 'file', id: 1 }), SECRET));
});
test('only files and archive entries can be handed off', () => {
assert.throws(() => handoff.createToken(SECRET, { kind: 'model', id: 1 }), /Unknown hand-off kind/);
});
test('link file names stay plain ASCII and keep their extension', () => {
assert.equal(handoff.urlName('Toy Keychain (v2).3mf'), 'Toy_Keychain_v2.3mf');
assert.equal(handoff.urlName('Café déco.STL'), 'Cafe_deco.stl');
assert.equal(handoff.urlName('日本.stl'), 'model.stl');
assert.equal(handoff.urlName('../../etc/passwd.obj'), 'passwd.obj');
});

97
test/makerworld.test.js Normal file
View file

@ -0,0 +1,97 @@
// MakerWorld importer helpers (lib/importers/makerworld.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const makerworld = require('../lib/importers/makerworld');
const page = (design) =>
`<html><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { design } } })}</script></html>`;
test('printable instances and the license are read from the page data', () => {
const html = page({
license: 'Standard Digital File License',
instances: [{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' }, { id: 650089 }],
});
assert.deepEqual(makerworld.parseInstances(html), [
{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' },
{ id: 650089, title: '' },
]);
assert.equal(makerworld.parseLicense(html), 'Standard Digital File License');
assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []);
});
test('a download answer without a link says why, and flags a refused token', () => {
assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null);
const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' });
assert.equal(refused.tokenRejected, true);
assert.match(refused.message, /token \(it has probably expired\)/);
assert.deepEqual(makerworld.downloadProblem({ code: 7, error: 'Model not found' }),
{ message: 'MakerWorld didn’t give a download link: Model not found', tokenRejected: false });
assert.deepEqual(makerworld.downloadProblem(null), { message: 'MakerWorld didn’t give a download link', tokenRejected: false });
});
const collectionPage = (favorite, favoriteDesigns) =>
`<html><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { favorite, favoriteDesigns } } })}</script></html>`;
test('a collection page gives its models, name and total', () => {
const html = collectionPage(
{ id: 31103374, title: 'markets', designCnt: 3 },
{ total: 3, hits: [
{ id: 719274, slug: 'skeleton-bookmarks', title: 'Skeleton Bookmarks' },
{ id: 926432, slug: 'doomhammer', title: 'Doomhammer' },
{ id: 12345, title: 'No slug' },
] });
const collection = makerworld.parseCollection(html);
assert.equal(collection.id, '31103374');
assert.equal(collection.title, 'markets');
assert.equal(collection.total, 3);
assert.deepEqual(collection.designs.map((d) => d.id), ['719274', '926432', '12345']);
assert.equal(makerworld.designUrl(collection.designs[0]), 'https://makerworld.com/en/models/719274-skeleton-bookmarks');
assert.equal(makerworld.designUrl(collection.designs[2]), 'https://makerworld.com/en/models/12345');
// The page can hold fewer models than the collection does.
const partial = makerworld.parseCollection(collectionPage({ id: 1, title: 'big', designCnt: 120 }, { total: 120, hits: [{ id: 5, slug: 'a', title: 'A' }] }));
assert.equal(partial.total, 120);
assert.equal(partial.designs.length, 1);
});
test('a page without a collection (private, or Cloudflare) parses as nothing', () => {
assert.equal(makerworld.parseCollection('<html><title>Just a moment...</title></html>'), null);
assert.equal(makerworld.parseCollection(collectionPage(null, null)), null);
// What MakerWorld serves for a private collection when the request isn't signed in.
assert.equal(makerworld.parseCollection(collectionPage({ id: -1, title: '', status: 0, designCnt: 0 }, { total: 0, hits: [] })), null);
});
test('more models are read from the collection’s own load-more call', () => {
assert.equal(makerworld.collectionDesignsUrl('1465524', { offset: 64, limit: 64, seed: 0 }),
'https://makerworld.com/api/v1/design-service/favorites/1465524/designs?offset=64&limit=64&seed=0');
assert.equal(makerworld.collectionDesignsUrl('7'),
'https://makerworld.com/api/v1/design-service/favorites/7/designs?offset=0&limit=64');
const page = makerworld.parseCollectionDesigns({ total: 535, seed: 0, hits: [{ id: 516328, slug: 'a-thing', title: 'A thing' }, { id: 887552 }] });
assert.equal(page.total, 535);
assert.equal(page.seed, 0);
assert.deepEqual(page.designs, [{ id: '516328', slug: 'a-thing', title: 'A thing' }, { id: '887552', slug: '', title: '' }]);
assert.deepEqual(makerworld.parseCollectionDesigns(null), { designs: [], total: 0, seed: undefined });
});
test('collection links are recognised', () => {
assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/en/collections/31103374-markets'), '31103374');
assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/collections/31103374'), '31103374');
assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/en/models/719274-skeleton-bookmarks'), null);
});
test('MakerWorld’s anti-bot check is told apart from a refused token', () => {
const challenge = { code: 1, error: 'We need to confirm that you are not a robot.' };
assert.equal(makerworld.isChallenge(challenge), true);
assert.equal(makerworld.isChallenge({ url: 'https://cdn.example/x.3mf' }), false);
assert.equal(makerworld.isChallenge({ code: 1, error: 'Please log in to download models.' }), false);
const blocked = makerworld.downloadProblem(challenge);
assert.equal(blocked.challenged, true);
assert.equal(blocked.tokenRejected, false);
assert.match(blocked.message, /anti-bot/);
const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' });
assert.equal(refused.tokenRejected, true);
assert.equal(refused.challenged, undefined);
});

94
test/printer.test.js Normal file
View file

@ -0,0 +1,94 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const printer = require('../lib/printer');
test('AMS trays are read into material, colour and how much is left', () => {
const trays = printer.describeAms([{ id: '0', tray: [
{ id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', remain: 82 },
{ id: '1', tray_type: '', tray_color: '00000000' }, // empty slot
{ id: '2', tray_type: 'PETG', tray_color: '000000FF', remain: -1 }, // not a Bambu spool
] }]);
assert.deepEqual(trays[0], {
unit: 0, slot: 0, empty: false, material: 'PLA', colour: '#FFFFFF',
profile: 'GFA00', brand: 'PLA Basic', remain: 82, uuid: null, tagUid: null, idName: null,
weight: null, type: 'Basic',
});
assert.equal(trays[1].empty, true);
assert.equal(trays[1].material, null);
assert.equal(trays[1].colour, null, 'an empty slot has no colour, whatever the printer says');
assert.equal(trays[2].remain, null, '-1 means the printer can’t tell');
assert.equal(trays[2].colour, '#000000', 'the alpha byte is dropped');
});
test('a Bambu spool’s RFID identity is kept, and an unread tag is not invented', () => {
const [tagged] = printer.describeAms([{ id: '0', tray: [{
id: '0', tray_type: 'PLA', tray_color: 'C12E1FFF',
tray_uuid: 'A1B2C3D4E5F60718293A4B5C6D7E8F90', tag_uid: '04AB12CD34EF5680', tray_id_name: 'A00-R0',
}] }]);
assert.equal(tagged.uuid, 'A1B2C3D4E5F60718293A4B5C6D7E8F90');
assert.equal(tagged.tagUid, '04AB12CD34EF5680');
assert.equal(tagged.idName, 'A00-R0');
const [plain] = printer.describeAms([{ id: '0', tray: [{
id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF',
tray_uuid: '00000000000000000000000000000000', tag_uid: '0000000000000000',
}] }]);
assert.equal(plain.uuid, null, 'all zeros means no tag was read');
assert.equal(plain.tagUid, null);
assert.equal(plain.idName, null);
});
test('a printer with no AMS reports nothing rather than failing', () => {
assert.deepEqual(printer.describeAms(undefined), []);
assert.deepEqual(printer.describeAms([{ id: '0' }]), []);
});
test('the external spool holder is read the same way as a tray', () => {
const ext = printer.describeTray({ id: '254', tray_type: 'PLA', tray_color: 'C12E1FFF', remain: 40 });
assert.equal(ext.unit, null);
assert.equal(ext.material, 'PLA');
assert.equal(ext.colour, '#C12E1F');
assert.equal(ext.remain, 40);
});
test('connection failures are explained in plain words', () => {
assert.equal(printer.friendlyError(new Error('Connection refused: Not authorized')),
'The printer refused the access code.');
assert.equal(printer.friendlyError(new Error('connect ECONNREFUSED 192.0.2.10:8883')),
'Nothing answered on port 8883 at that address.');
assert.equal(printer.friendlyError(new Error('connect EHOSTUNREACH 192.0.2.10:8883')),
'Couldn’t reach the printer from the server.');
assert.equal(printer.friendlyError(null), 'The connection failed.');
});
test('the product line is what follows the material in the brand', () => {
assert.equal(printer.productLine('PLA Basic', 'PLA'), 'Basic');
assert.equal(printer.productLine('PLA Silk+', 'PLA'), 'Silk+');
assert.equal(printer.productLine('PETG HF', 'PETG'), 'HF');
assert.equal(printer.productLine('Generic', 'PLA'), 'Generic', 'a brand without the material is kept whole');
assert.equal(printer.productLine('', 'PLA'), null);
});
test('a tray reports its weight when new', () => {
const t = printer.describeTray({ id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_weight: '1000' });
assert.equal(t.weight, 1000);
assert.equal(printer.describeTray({ id: '0', tray_type: 'PLA', tray_weight: '0' }).weight, null);
});
test('status is read from a report, and fields a report leaves out stay out', () => {
const { readStatus } = require('../lib/printer');
const full = readStatus({
gcode_state: 'RUNNING', mc_percent: 42, mc_remaining_time: 73, subtask_name: 'Benchy',
layer_num: 80, total_layer_num: 210, nozzle_temper: 219.6, nozzle_target_temper: 220,
bed_temper: 59.8, bed_target_temper: 60, spd_lvl: 2, print_error: 0,
hms: [{ attr: 0x07002000, code: 0x00020001 }],
});
assert.deepEqual(full, {
state: 'RUNNING', progress: 42, remainingMin: 73, file: 'Benchy', layer: 80, totalLayers: 210,
nozzle: 219.6, nozzleTarget: 220, bed: 59.8, bedTarget: 60, speed: 'Standard', printError: null,
hms: ['0700_2000_0002_0001'],
});
// A delta with only the nozzle temperature says nothing about the rest.
assert.deepEqual(readStatus({ nozzle_temper: 200 }), { nozzle: 200 });
assert.equal(readStatus({ print_error: 117473298 }).printError, '07008012');
});

View file

@ -0,0 +1,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { hello, jpegSize } = require('../lib/printerCamera');
test('the camera hello carries the username and code in the right places', () => {
const buf = hello('12345678');
assert.equal(buf.length, 80);
assert.equal(buf.readUInt32LE(0), 0x40);
assert.equal(buf.readUInt32LE(4), 0x3000);
assert.equal(buf.subarray(16, 20).toString('ascii'), 'bblp');
assert.equal(buf.subarray(16, 48).toString('ascii').replace(/\0+$/, ''), 'bblp');
assert.equal(buf.subarray(48, 80).toString('ascii').replace(/\0+$/, ''), '12345678');
});
test('a frame’s size is read from the JPEG itself', () => {
// A JPEG header: start marker, a comment segment, then the frame header.
const jpeg = Buffer.concat([
Buffer.from([0xff, 0xd8]),
Buffer.from([0xff, 0xfe, 0x00, 0x04, 0x61, 0x62]), // comment, skipped
Buffer.from([0xff, 0xc0, 0x00, 0x11, 0x08, 0x04, 0x38, 0x07, 0x80]), // 1080 × 1920
]);
assert.deepEqual(jpegSize(jpeg), { height: 1080, width: 1920 });
assert.equal(jpegSize(Buffer.from([0xff, 0xd8, 0x00])), null);
});

117
test/printerLive.test.js Normal file
View file

@ -0,0 +1,117 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
// A stand-in for the MQTT client, so no network is involved.
const clients = [];
const live = require('../lib/printerLive');
live.setConnector((url, options) => {
const c = new EventEmitter();
Object.assign(c, {
url, options, connected: false, reconnecting: false, published: [], ended: false,
subscribe: (_topic, cb) => cb(null),
publish: (topic, body) => c.published.push({ topic, body: JSON.parse(body) }),
end: () => { c.ended = true; },
reconnect: () => {},
});
clients.push(c);
return c;
});
const report = (print) => Buffer.from(JSON.stringify({ print }));
const printerRow = { id: 1, host: '10.0.0.5', serial: 'SER1', access_code: 'code1' };
const tray = (id, type, colour) => ({ id: String(id), tray_type: type, tray_color: colour, tray_uuid: type ? `U${id}` : '0' });
test('live status merges partial reports, and a tray change syncs spools once', async (t) => {
t.after(() => live.stopAll());
const synced = [];
live.setTraysHandler(async (id, trays) => { synced.push({ id, trays }); return { updated: trays.length }; });
live.reconcile([printerRow, { id: 2, host: '', serial: 'X', access_code: 'y' }]);
assert.equal(clients.length, 1, 'a printer missing its address isn’t connected');
const c = clients[0];
assert.equal(c.options.password, 'code1');
c.connected = true;
c.emit('connect');
assert.equal(c.published[0].topic, 'device/SER1/request');
assert.equal(c.published[0].body.pushing.command, 'pushall');
c.emit('message', 'device/SER1/report', report({
gcode_state: 'RUNNING', mc_percent: 10, nozzle_temper: 220,
ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF'), tray(1, '', '')] }] },
}));
c.emit('message', 'device/SER1/report', report({ mc_percent: 11 }));
const snap = live.snapshot(1);
assert.equal(snap.connected, true);
assert.equal(snap.status.state, 'RUNNING');
assert.equal(snap.status.progress, 11);
assert.equal(snap.status.nozzle, 220, 'kept from the earlier report');
assert.deepEqual(snap.loaded.map((l) => [l.slot, l.empty, l.colour, l.tagged]),
[[0, false, '#FF0000', true], [1, true, null, false]]);
assert.equal(JSON.stringify(snap).includes('code1'), false, 'no access code in a snapshot');
// "Test & sync" asks again and syncs straight away.
const pending = live.refresh(1, 3000);
c.emit('message', 'device/SER1/report', report({ ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF'), tray(1, '', '')] }] } }));
const result = await pending;
assert.equal(result.connected, true);
assert.equal(result.sync.updated, 2);
assert.equal(synced.length, 1);
assert.equal(c.published.at(-1).body.pushing.command, 'pushall');
});
test('a busy printer’s steady reports don’t hold back a spool sync', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => live.stopAll());
clients.length = 0;
const synced = [];
live.setTraysHandler(async (id) => { synced.push(id); return {}; });
live.reconcile([{ ...printerRow, id: 5, serial: 'SER5' }]);
const c = clients[0];
c.connected = true;
c.emit('connect');
c.emit('message', 't', report({ ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF')] }] } }));
for (let i = 0; i < 10; i += 1) {
t.mock.timers.tick(1000);
c.emit('message', 't', report({ mc_percent: i }));
}
assert.equal(synced.length, 1, 'synced once, five seconds after the change');
// The same trays again change nothing.
c.emit('message', 't', report({ ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF')] }] } }));
t.mock.timers.tick(6000);
assert.equal(synced.length, 1);
});
test('the tray feeding the toolhead is worked out from tray_now', (t) => {
t.after(() => live.stopAll());
clients.length = 0;
live.setTraysHandler(async () => ({}));
live.reconcile([{ ...printerRow, id: 7, serial: 'SER7' }, { ...printerRow, id: 8, serial: 'SER8' }]);
const [a, b] = clients;
const units = (ids) => ({ ams: ids.map((id) => ({ id: String(id), tray: [tray(0, 'PLA', 'FF0000FF')] })) });
// Two AMS units numbered 0 and 1: tray 6 is the second unit's third slot.
a.emit('message', 't', report({ ams: { ...units([0, 1]), tray_now: '6' } }));
assert.deepEqual(live.snapshot(7).status.active, { unit: 1, slot: 2 });
// A later report changing only tray_now still counts.
a.emit('message', 't', report({ ams: { tray_now: '254' } }));
assert.deepEqual(live.snapshot(7).status.active, { external: true });
a.emit('message', 't', report({ ams: { tray_now: '255' } }));
assert.equal(live.snapshot(7).status.active, null);
// An AMS lite numbered 16 (as on the A2L): tray 1 is its second slot.
b.emit('message', 't', report({ ams: { ...units([16]), tray_now: '1' } }));
assert.deepEqual(live.snapshot(8).status.active, { unit: 16, slot: 1 });
});
test('changing a printer’s code reopens its connection; removing it closes it', () => {
clients.length = 0;
live.reconcile([printerRow]);
live.reconcile([printerRow]);
assert.equal(clients.length, 1, 'no change, no new connection');
live.reconcile([{ ...printerRow, access_code: 'code2' }]);
assert.equal(clients.length, 2);
assert.equal(clients[0].ended, true);
live.reconcile([]);
assert.equal(clients[1].ended, true);
assert.deepEqual(live.snapshot(1), { live: false });
});

19
test/printers.test.js Normal file
View file

@ -0,0 +1,19 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { publicPrinter, measures } = require('../lib/printers');
test('a printer sent to the browser never carries its access code', () => {
const p = publicPrinter({ id: 1, nickname: null, model: 'A1', serial: 'S1', access_code: 'secret' });
assert.equal('access_code' in p, false);
assert.equal(p.codeSet, true);
assert.equal(publicPrinter({ id: 2, access_code: null }).codeSet, false);
});
test('only AMS types that weigh filament count as measuring', () => {
assert.equal(measures('ams_lite'), false);
assert.equal(measures('none'), false);
assert.equal(measures(null), false);
assert.equal(measures('ams'), true);
assert.equal(measures('ams_2_pro'), true);
assert.equal(measures('ams_ht'), true);
});

View file

@ -11,6 +11,7 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { execFileSync, spawnSync } = require('child_process'); const { execFileSync, spawnSync } = require('child_process');
const AdmZip = require('adm-zip');
const { PYTHON } = require('../lib/python'); const { PYTHON } = require('../lib/python');
const TOOLS = path.join(__dirname, '..', 'tools'); const TOOLS = path.join(__dirname, '..', 'tools');
@ -49,6 +50,21 @@ test('project info lists plates, their previews and the filaments', { skip }, ()
assert.ok(!fs.existsSync(path.join(images, '2.png'))); assert.ok(!fs.existsSync(path.join(images, '2.png')));
}); });
test('project info names the app that saved the 3MF', { skip }, () => {
const info = (file) => lastJson(execFileSync(PYTHON, [path.join(TOOLS, 'threemf_info.py'), file], { encoding: 'utf8' }));
const zip = new AdmZip(FIXTURE);
const model = zip.readAsText('3D/3dmodel.model').replace(/\s*<metadata name="Application">[^<]*<\/metadata>/, '');
const plain = path.join(tmp, 'plain.3mf');
zip.updateFile('3D/3dmodel.model', Buffer.from(model));
zip.writeZip(plain);
assert.equal(info(plain).application, null);
const saved = path.join(tmp, 'saved.3mf');
zip.updateFile('3D/3dmodel.model', Buffer.from(model.replace(/<model\b[^>]*>/, (m) => `${m}\n <metadata name="Application">BambuStudio-02.01.01.52</metadata>`)));
zip.writeZip(saved);
assert.equal(info(saved).application, 'BambuStudio-02.01.01.52');
});
test('a plate converts alone, coloured by its part’s filament, without negative parts', { skip }, () => { test('a plate converts alone, coloured by its part’s filament, without negative parts', { skip }, () => {
const { summary, colours } = convert(1); const { summary, colours } = convert(1);
assert.equal(summary.faces, 12); assert.equal(summary.faces, 12);

98
test/spoolSync.test.js Normal file
View file

@ -0,0 +1,98 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { planSync, slotKey } = require('../lib/spoolSync');
const { paletteMatch } = require('../lib/filaments');
const palette = require('../db/bambu-filaments.json').filaments;
const SERIAL = 'PRINTER1';
const tray = (slot, hex, uuid, extra = {}) => ({
unit: 0, slot, empty: false, material: 'PLA', type: 'Basic', colour: hex, brand: 'PLA Basic',
uuid, tagUid: uuid ? `TAG${slot}` : null, idName: null, weight: 1000, ...extra,
});
test('a tagged spool the shelf has never seen is created and named from the filament list', () => {
const { actions, untracked } = planSync([], [tray(0, '#C12E1F', 'U-RED', { idName: 'A00-R0' })], SERIAL, palette);
assert.equal(untracked.length, 0);
assert.equal(actions.length, 1);
assert.equal(actions[0].op, 'create');
assert.equal(actions[0].slot, 'PRINTER1/0/0');
assert.equal(actions[0].spool.colour, 'Red');
assert.equal(actions[0].spool.code, '10200');
assert.equal(actions[0].spool.colour_hex, '#C12E1F');
assert.equal(actions[0].spool.product_code, 'A00-R0');
assert.equal(actions[0].spool.total_g, 1000);
assert.equal(actions[0].spool.remaining_g, null, 'an AMS lite can’t say how much is left');
});
test('a spool the shelf already knows is moved, not added again', () => {
const existing = [{ id: 7, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-BLACK',
printer_serial: SERIAL, ams_slot: 'PRINTER1/0/3' }];
const { actions } = planSync(existing, [tray(1, '#000000', 'U-BLACK')], SERIAL, palette);
assert.deepEqual(actions, [{ op: 'update', id: 7, slot: 'PRINTER1/0/1', printerSerial: SERIAL, remaining: null }]);
});
test('a spool added by hand is adopted by the printer rather than duplicated', () => {
// The Jade White spool added before the printer was connected.
const existing = [{ id: 1, material: 'PLA', colour_hex: '#ffffff', rfid_uuid: null, printer_serial: null, ams_slot: null }];
const { actions } = planSync(existing, [tray(3, '#FFFFFF', 'U-WHITE', { idName: 'A00-W1' })], SERIAL, palette);
assert.equal(actions.length, 1);
assert.equal(actions[0].op, 'adopt');
assert.equal(actions[0].id, 1);
assert.equal(actions[0].uuid, 'U-WHITE');
assert.equal(actions[0].code, '10100');
});
test('one hand-added spool is adopted once, even with two matching trays', () => {
const existing = [{ id: 1, material: 'PLA', colour_hex: '#FFFFFF', rfid_uuid: null }];
const { actions } = planSync(existing, [tray(0, '#FFFFFF', 'U-W1'), tray(1, '#FFFFFF', 'U-W2')], SERIAL, palette);
assert.deepEqual(actions.map((a) => a.op), ['adopt', 'create']);
});
test('a hand-added spool of another colour or material is left alone', () => {
const existing = [
{ id: 1, material: 'PLA', colour_hex: '#FF0000', rfid_uuid: null }, // wrong colour
{ id: 2, material: 'PETG', colour_hex: '#FFFFFF', rfid_uuid: null }, // wrong material
];
const { actions } = planSync(existing, [tray(0, '#FFFFFF', 'U-W')], SERIAL, palette);
assert.deepEqual(actions.map((a) => a.op), ['create']);
});
test('an untagged spool is listed, not guessed at', () => {
const { actions, untracked } = planSync([], [tray(2, '#123456', null)], SERIAL, palette);
assert.deepEqual(actions, []);
assert.deepEqual(untracked, [{ slot: 'PRINTER1/0/2', material: 'PLA', colour: '#123456' }]);
});
test('a spool this printer no longer reports is unloaded; other printers’ spools are not', () => {
const existing = [
{ id: 5, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-GONE', printer_serial: SERIAL, ams_slot: 'PRINTER1/0/0' },
{ id: 6, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-ELSEWHERE', printer_serial: 'OTHER', ams_slot: 'OTHER/0/0' },
];
const { actions } = planSync(existing, [], SERIAL, palette);
assert.deepEqual(actions, [{ op: 'unload', id: 5 }]);
});
test('empty trays are ignored, and the external holder has its own slot key', () => {
assert.deepEqual(planSync([], [{ empty: true, unit: 0, slot: 0 }], SERIAL, palette).actions, []);
assert.equal(slotKey(SERIAL, { unit: null, slot: 254 }), 'PRINTER1/ext/254');
});
test('palette matching names a colour within its product line, and doesn’t guess', () => {
assert.deepEqual(paletteMatch(palette, 'PLA', 'Basic', '#FFFFFF'), { name: 'Jade White', code: '10100', type: 'Basic' });
assert.equal(paletteMatch(palette, 'PLA', 'Matte', '#FFFFFF').name, 'Ivory White');
assert.equal(paletteMatch(palette, 'PLA', 'Basic', '#123456'), null);
});
test('an AMS that measures fills in grams left; an AMS lite never does', () => {
const trays = [tray(0, '#C12E1F', 'U-RED', { remain: 42 })];
const lite = planSync([], trays, SERIAL, palette, { measures: false });
assert.equal(lite.actions[0].spool.remaining_g, null);
const pro = planSync([], trays, SERIAL, palette, { measures: true });
assert.equal(pro.actions[0].spool.remaining_g, 420);
const existing = [{ id: 7, material: 'PLA', colour_hex: '#C12E1F', rfid_uuid: 'U-RED', printer_serial: SERIAL, ams_slot: 'PRINTER1/0/0' }];
assert.equal(planSync(existing, trays, SERIAL, palette, { measures: true }).actions[0].remaining, 420);
// No reading at all leaves the stored amount alone.
const unknown = [tray(0, '#C12E1F', 'U-RED', { remain: null })];
assert.equal(planSync(existing, unknown, SERIAL, palette, { measures: true }).actions[0].remaining, null);
});

103
tools/backup.sh Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Nightly backup: a dated dump of the database, and a mirror of the model files.
#
# Run it from anywhere — it finds the folder holding docker-compose.yml itself.
# Printhive keeps running throughout; pg_dump takes a consistent snapshot.
#
# ./tools/backup.sh
#
# Settings (in .env beside docker-compose.yml, or the environment):
# BACKUP_DIR where backups go (default: ./backups)
# UPLOADS_DIR the model files (default: asked of the running container)
# KEEP_DAYS dumps to keep, in days (default: 14)
set -euo pipefail
cd "$(dirname "$0")/.."
# A setting from the environment, else from .env, else the fallback. Tolerates
# "export NAME=", quotes, spaces and Windows line endings.
setting() { # name, fallback
local from_env="${!1:-}"
if [ -n "$from_env" ]; then echo "$from_env"; return; fi
if [ -f .env ]; then
local line
line="$(tr -d '\r' < .env \
| grep -E "^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=" \
| tail -1 | cut -d= -f2- \
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/")"
if [ -n "$line" ]; then echo "$line"; return; fi
fi
echo "$2"
}
BACKUP_DIR="$(setting BACKUP_DIR ./backups)"
KEEP_DAYS="$(setting KEEP_DAYS 14)"
stamp="$(date +%Y-%m-%d)"
# Where the model files really are: ask the running container, rather than
# trusting a setting that may have drifted from how the stack was deployed.
UPLOADS_DIR="${UPLOADS_DIR:-}"
if [ -z "$UPLOADS_DIR" ]; then
UPLOADS_DIR="$(docker inspect printhive-app \
--format '{{range .Mounts}}{{if eq .Destination "/app/uploads"}}{{.Source}}{{end}}{{end}}' 2>/dev/null || true)"
fi
if [ -z "$UPLOADS_DIR" ]; then
echo "$(date '+%F %T') FAILED: couldn’t find where /app/uploads comes from — is printhive-app running?" >&2
exit 1
fi
echo "$(date '+%F %T') files: $UPLOADS_DIR → backups: $BACKUP_DIR"
# A path the container can see but this machine can't (a stack manager deploying
# from inside a container does that) would back up nothing at all.
if [ ! -d "$UPLOADS_DIR" ]; then
echo "$(date '+%F %T') FAILED: $UPLOADS_DIR isn’t reachable from here — set UPLOADS_DIR to its path on this machine" >&2
exit 1
fi
if [ -z "$(ls -A "$UPLOADS_DIR" 2>/dev/null)" ]; then
echo "$(date '+%F %T') FAILED: $UPLOADS_DIR is empty — refusing to mirror nothing over a good backup" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR/db"
# --- the database -----------------------------------------------------------
# Written to a .part file first, so an interrupted run never leaves behind
# something that looks like a good backup.
# An empty library where backups already exist means something is wrong with
# the stack, not that you deleted everything: don't write that over good ones.
models="$(docker compose exec -T db psql -U printhive -d printhive -tAc 'select count(*) from models' 2>/dev/null | tr -d '[:space:]' || true)"
if [ "$models" = "0" ] && ls "$BACKUP_DIR"/db/printhive-*.dump >/dev/null 2>&1; then
echo "$(date '+%F %T') FAILED: the database has no models, but older backups do — leaving them alone" >&2
exit 1
fi
dump="$BACKUP_DIR/db/printhive-$stamp.dump"
# Dumped inside the container and read back there — a dump that can't be listed
# is no backup at all, and pg_restore can only list a real file, not a pipe.
if ! docker compose exec -T db sh -c \
'pg_dump -U printhive -Fc printhive > /tmp/printhive.dump && pg_restore -l /tmp/printhive.dump > /dev/null'; then
echo "$(date '+%F %T') FAILED: couldn’t dump the database — keeping the old backups" >&2
docker compose exec -T db rm -f /tmp/printhive.dump || true
exit 1
fi
docker compose exec -T db cat /tmp/printhive.dump > "$dump.part"
docker compose exec -T db rm -f /tmp/printhive.dump || true
if [ ! -s "$dump.part" ]; then
echo "$(date '+%F %T') FAILED: the dump came out empty — keeping the old backups" >&2
rm -f "$dump.part"
exit 1
fi
mv "$dump.part" "$dump"
echo "$(date '+%F %T') database → $dump ($(du -h "$dump" | cut -f1))"
# Older dumps go, so the folder doesn't grow for ever.
find "$BACKUP_DIR/db" -name 'printhive-*.dump' -type f -mtime "+$KEEP_DAYS" -delete
# --- the model files --------------------------------------------------------
# A mirror, not a history: it matches what's there now, deletions included.
# Thumbnails, previews and extracted archives are left out — they rebuild.
rsync -a --delete "$UPLOADS_DIR/" "$BACKUP_DIR/uploads/"
echo "$(date '+%F %T') files → $BACKUP_DIR/uploads ($(du -sh "$BACKUP_DIR/uploads" | cut -f1))"

View file

@ -33,6 +33,7 @@ def main():
return 1 return 1
print(json.dumps({ print(json.dumps({
"application": project["application"],
"plates": [{"index": p["index"], "name": p["name"], "objectCount": len(p["objects"]), "plates": [{"index": p["index"], "name": p["name"], "objectCount": len(p["objects"]),
"thumbnail": bool(p["thumbnail"] or p["thumbnail_small"])} for p in project["plates"]], "thumbnail": bool(p["thumbnail"] or p["thumbnail_small"])} for p in project["plates"]],
"filaments": project["filaments"], "filaments": project["filaments"],

View file

@ -159,16 +159,38 @@ def load(path):
return meshes, placements return meshes, placements
def _application(z, names):
"""The app that wrote the file, from the root model's <metadata name="Application">.
Metadata comes before the geometry, so reading stops at <resources>."""
root = _root_part(z)
if root not in names:
return None
try:
with z.open(root) as fh:
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
name = _local(el.tag)
if event == "start":
if name in ("resources", "build"):
break
elif name == "metadata" and el.get("name") == "Application":
return (el.text or "").strip() or None
except etree.XMLSyntaxError:
pass
return None
def read_project(path): def read_project(path):
"""Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent: """Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent:
{plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}], {application: "BambuStudio-02.01.01.52" | None,
plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
filaments: [{colour, type}], filaments: [{colour, type}],
objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}} objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}}
""" """
project = {"plates": [], "filaments": [], "objects": {}} project = {"application": None, "plates": [], "filaments": [], "objects": {}}
with zipfile.ZipFile(path) as z: with zipfile.ZipFile(path) as z:
names = set(z.namelist()) names = set(z.namelist())
project["application"] = _application(z, names)
if "Metadata/project_settings.config" in names: if "Metadata/project_settings.config" in names:
try: try:

68
tools/volumes-to-folders.sh Executable file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Copies Printhive's data out of Docker's named volumes into ./data, for the
# switch to host folders. Run it on the server, from the folder holding
# docker-compose.yml, with the stack stopped.
#
# It only copies: the named volumes are left exactly as they were, so if
# anything looks wrong you can put the old docker-compose.yml back and start
# again with nothing lost.
set -euo pipefail
# Same settings Compose uses, so the copy lands where the stack will look.
if [ -f .env ]; then
DATA_DIR="${DATA_DIR:-$(grep -E '^DATA_DIR=' .env | tail -1 | cut -d= -f2-)}"
PGDATA_DIR="${PGDATA_DIR:-$(grep -E '^PGDATA_DIR=' .env | tail -1 | cut -d= -f2-)}"
fi
DATA_DIR="${DATA_DIR:-./data}"
PGDATA_DIR="${PGDATA_DIR:-./data/pgdata}"
VOLUMES=(pgdata uploads thumbnails glb extracted)
# Where each volume's contents should end up.
target_for() {
case "$1" in
pgdata) echo "$PGDATA_DIR" ;;
*) echo "$DATA_DIR/$1" ;;
esac
}
# Compose names volumes "<project>_<name>", the project being this folder's name
# unless it's set elsewhere.
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$(pwd)")}"
if docker compose ps --status running --quiet 2>/dev/null | grep -q .; then
echo "Printhive is still running. Stop it first: docker compose down" >&2
exit 1
fi
echo "Project: $PROJECT"
echo "Files: $DATA_DIR"
echo "Database: $PGDATA_DIR"
echo
for name in "${VOLUMES[@]}"; do
volume="${PROJECT}_${name}"
target="$(target_for "$name")"
if ! docker volume inspect "$volume" >/dev/null 2>&1; then
echo "· $volume — no such volume, skipping (nothing to copy)"
mkdir -p "$target"
continue
fi
if [ -d "$target" ] && [ -n "$(ls -A "$target" 2>/dev/null)" ]; then
echo "· $target already has files in it — skipping, so nothing is overwritten" >&2
continue
fi
mkdir -p "$target"
# -a keeps ownership and timestamps, which Postgres's data folder needs.
docker run --rm \
-v "$volume":/from:ro \
-v "$(cd "$(dirname "$target")" && pwd)/$(basename "$target")":/to \
alpine sh -c 'cp -a /from/. /to/'
echo "· $volume → $target ($(du -sh "$target" | cut -f1))"
done
echo
echo "Done. Start it up again with: docker compose up -d"
echo "Once you're happy, the old volumes can be removed with:"
for name in "${VOLUMES[@]}"; do echo " docker volume rm ${PROJECT}_${name}"; done