Commit graph

30 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
dlawler489
ea2afeac0e Make thumbnails on the server in the background
- The background worker now also gives models without a thumbnail one:
  an embedded 3MF/plate preview, else a picture uploaded with the model,
  else a 3MF preview or picture inside one of its archives, else a render
  of an STL/OBJ/3MF (loose or inside an archive).
- tools/render_thumbnail.py renders without a GPU: meshes are simplified
  per distinct part (the 8M-face lion project renders in ~8 s / ~200 MB),
  3MF projects show their first plate in filament colours, and only
  camera-facing faces are painted (inside-out meshes and mirrored parts are
  re-oriented first).
- models.thumbnail_status stops retries when there's nothing to use; adding
  files clears it. The browser now only renders FBX/STEP/3DM thumbnails.
- The library checks back while fingerprints or thumbnails are pending and
  refreshes when they land.
- Python sidecar needs pillow and fast-simplification; compose keeps the
  archive extraction cache in a volume. README covers the phase 2 features.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:54:26 +10:00
dlawler489
fcb418f119 Show 3MF projects plate by plate in their filament colours
- threemf_stream reads Bambu Studio / OrcaSlicer project metadata: plates
  (name, objects, preview images), filaments, and each part's type and
  extruder. Placements now record which build item and part they belong to.
- convert_3mf.py leaves out negative/modifier/support parts, colours each
  part by its filament (a part's extruder 0 falls back to its object's), and
  takes --plate N. Geometry is still stored once per (part, colour).
- New threemf_info.py lists plates and filaments without loading geometry
  and extracts plate preview images; served via /api/{files,entries}/:id/project.
- /glb?plate=N builds and caches a per-plate preview. Cached GLB names carry
  a converter version, so previews made before colours are rebuilt.
- Model detail: plate chips (with the slicer's plate images) under the
  viewer — multi-plate projects open on plate 1 — plus "All plates", and a
  Filaments section with colour swatches.
- Fingerprints leave out negative/modifier parts, so a project matches the
  model it prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:49:58 +10:00
dlawler489
d4b7e29348 Browse archives, preview companion files, and view FBX/STEP/3DM/OBJ+MTL
Archives
- ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background into
  archive_entries (7-Zip compiled to WASM in a worker thread; node-tar for
  tar). macOS/Windows clutter and paths that could escape the extraction
  folder are skipped.
- Models inside archives are extracted once per archive to hash and
  geometry-fingerprint them, so duplicates are found inside packs too.
  Pictures, PDFs and readmes are listed but never matched.
- New routes list an archive's entries and serve/convert a single entry,
  extracted on first use and cached under extracted/.

Viewer
- Model detail lists what's inside each archive with view/download links;
  opening an entry adds a viewer tab.
- New 3D formats: FBX (with sibling textures), OBJ with its MTL and
  textures, STEP/STP via occt-import-js, and Rhino 3DM via rhino3dm — the
  WASM readers are served from node_modules, loaded on first use.
- Pictures, PDFs and text files (jpg/png/gif/webp/bmp/svg, pdf, txt/md)
  can be uploaded and preview in the viewer; a model without an embedded
  3MF preview uses its first picture as the card thumbnail.
- Inline file responses send nosniff, and SVGs are sandboxed.
- GLB conversions write to a temp file and rename, so a half-written
  preview is never served; a later tab can't be overdrawn by a slow load.
- Better 3MF card image: the project thumbnail or plate 1 render, not a
  slicer pick map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:36:47 +10:00
dlawler489
502eefdec2 Stream-parse 3MF files so Bambu projects don't exhaust memory
trimesh's 3MF loader re-reads a whole production-extension part file for
every component that points into it. A Bambu Studio project with 884
components across four ~50 MB part files grew past 3 GB within seconds
and failed both the preview conversion and the geometry fingerprint.

- Add tools/threemf_stream.py: parses each part once with lxml iterparse,
  resolves each component to the single object it names, and returns each
  mesh once plus its placements. The same project now converts in ~8 s at
  ~380 MB peak (fingerprinting ~190 MB).
- convert_3mf.py and fingerprint.py use it; the GLB stores each object once
  and instances repeats. Stats are computed per placement (mirrored
  placements keep a positive volume), without concatenating meshes.
- Run sidecar scripts through one queue (lib/python.js) so a preview and
  background fingerprinting never load big meshes at the same time;
  previews go first, and 3MFs are fingerprinted one per run.
- Surface the converter's JSON error / timeout instead of "Command failed".
- Tests with a Bambu-style component fixture (shared part, mirrored
  placement, decoy object) and STL/3MF fingerprint parity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:02:00 +10:00
dlawler489
00a4b4ba07 Add duplicate finder, trash, notes, and library search/filters
- Fingerprint every file in the background: SHA-256 for all formats plus a
  trimesh geometry fingerprint for STL/OBJ/3MF, so renamed, re-exported or
  converted copies are still matched. Backfills existing files on startup.
- Duplicates view groups models that share files (exact copies vs same
  shape), recommends which to keep, merges tags/collections/notes into the
  kept model and trashes the rest; groups can be dismissed.
- Deleting now moves models to a restorable trash; files are only removed
  from disk on permanent delete or when the trash is emptied.
- Per-model notes with autosave.
- Search across names, notes, tags, files and designers; filter by format,
  source and license; include/exclude tags with any/all matching; sort and
  group by source or format. Preferences persist per browser.
- Add node:test unit tests for duplicate grouping and fingerprints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:06:09 +10:00
dlawler489
a2e08ecd03 License rules are the source of truth for sellability
All checks were successful
build-and-push / docker (push) Successful in 28s
- Changing a license policy applies to all models with that license, and
  opening Settings reconciles old imports to current rules (store.reconcileModelsToRules)
- Editing a model's license re-resolves its sellability from the rule
- Removed the per-model commercial-use override (set by license now)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:33:38 +10:00
dlawler489
2768a71c63 Add per-license commercial-use rules (settings + auto-register)
- license_rules table (name -> commercial); licenses auto-register on
  import with a best-effort seed, overridable in Settings (applies the
  choice to every model with that license)
- resolveCommercial() replaces ad-hoc inferCommercial across import/refresh
- GET/PUT /api/licenses; "License types" section in Settings
- Fix CC short codes: BY / BY-SA / BY-ND now classed sellable (MakerWorld
  stores e.g. "BY-SA"); NC variants stay non-commercial

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:20:28 +10:00
dlawler489
5af88eb5ce Add email/password auth + SMTP password resets
All checks were successful
build-and-push / docker (push) Successful in 29s
- users + password_resets tables; bcrypt hashing; JWT session in an
  httpOnly cookie (auth_secret auto-generated in settings)
- /api gated behind auth (except /api/config and /api/auth/*)
- First-run creates the account (no open registration after that);
  login, logout, forgot/reset-password flows
- SMTP settings (host/port/secure/user/pass/from) in Settings menu;
  nodemailer sends reset links
- Auth screen (sign in / create account / forgot / reset) gates the SPA;
  Sign out button; COOKIE_SECURE=true in compose for HTTPS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:33:11 +10:00
dlawler489
aabc0dd870 Add Docker packaging (Dockerfile, compose, sidecar deps)
- Dockerfile: node:22-bookworm-slim + Python venv with trimesh for the
  3MF->GLB sidecar (PYTHON_BIN env points the converter at it)
- docker-compose.yml: app + postgres 16 with healthcheck/depends_on,
  named volumes for pgdata/uploads/thumbnails/glb, token env passthrough
- .dockerignore, tools/requirements.txt
- converter.js honours PYTHON_BIN (defaults to local venv)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:51:06 +10:00
dlawler489
a37db15a3f Initial commit: Printhive 3D-print file library
Self-hosted Express + Postgres app for organizing STL/3MF/OBJ/etc. with:
- Model/file library, tags, collections, print settings
- URL import: Thingiverse (API), Printables (GraphQL), MakerWorld
  (FlareSolverr + token), cubee3d/other (metadata + manual files)
- In-browser 3D viewer; 3MF rendered via Python/trimesh GLB conversion
- Auto thumbnails, license/commercial-use tracking, in-app editing
- Settings (API tokens / FlareSolverr) stored in DB with .env fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:15:17 +10:00