diff --git a/README.md b/README.md
index a1d61df..ab55fd6 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ 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.
- **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 / 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".
+- **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.
- **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.
diff --git a/db/schema.sql b/db/schema.sql
index a417ffd..1182b0a 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -225,3 +225,38 @@ CREATE TABLE IF NOT EXISTS collection_suggestions (
);
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,
+ 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 | existing | error
+ error TEXT,
+ seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (watch_id, design_id)
+);
diff --git a/lib/collectionWatch.js b/lib/collectionWatch.js
new file mode 100644
index 0000000..9166600
--- /dev/null
+++ b/lib/collectionWatch.js
@@ -0,0 +1,235 @@
+// 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 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
+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));
+ 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 = 'imported') AS imported_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
+ 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
+ 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 = 'imported') AS imported_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 }) {
+ 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)
+ VALUES ('makerworld', $1, $2, $3, $4, $5, $6, $7, $8, 'checking') RETURNING id`,
+ [sourceId, url, collection.title, project.id, Boolean(aiTag), hours, collection.total, collection.designs.length]);
+ checkNow(watch.id).catch((err) => console.warn(`Watch ${watch.id} first check failed:`, err.message));
+ return get(watch.id);
+}
+
+async function update(id, { enabled, aiTag, intervalHours }) {
+ 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),
+ 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]);
+ 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;
+ const fresh = [];
+ for (const design of todo) {
+ const url = makerworld.designUrl(design);
+ try {
+ const result = await deps.importFromUrl(url, { projects: watch.project_id ? [watch.project_id] : [] });
+ 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);
+ await recordItem(watch.id, design, result.model.id, 'imported', result.filesError || null);
+ }
+ // A refused token means every later download fails too: stop and say so.
+ if (result.filesErrorReason === 'makerworld_token') {
+ problem = httpError(result.filesError, 502, 'makerworld_token');
+ break;
+ }
+ } catch (err) {
+ await recordItem(watch.id, design, null, 'error', err.message);
+ if (err.reason === 'makerworld_token') { problem = err; break; }
+ }
+ await sleep(BETWEEN_IMPORTS_MS);
+ }
+
+ const more = pending.length > todo.length;
+ 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 };
diff --git a/lib/importers/makerworld.js b/lib/importers/makerworld.js
index a4f32e4..8c02a54 100644
--- a/lib/importers/makerworld.js
+++ b/lib/importers/makerworld.js
@@ -46,10 +46,64 @@ function downloadProblem(answer) {
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(/