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>
This commit is contained in:
dlawler489 2026-09-16 08:53:35 +10:00
parent 66b62cbb1e
commit 0df38c83c7
10 changed files with 715 additions and 107 deletions

View file

@ -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 <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".
- **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. It imports a few per check and spaces them out; 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.

View file

@ -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)
);

223
lib/collectionWatch.js Normal file
View file

@ -0,0 +1,223 @@
// 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 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() {
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)
VALUES ('makerworld', $1, $2, $3, $4, $5, $6, $7, $8) 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);
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]);
importFound(watch, collection).catch((err) => console.error(`Watch ${id} import failed:`, err.message));
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() ORDER BY next_check_at LIMIT 1`);
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);
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 {
busy = false;
}
}
module.exports = { INTERVALS, init, start, list, get, add, update, remove, checkNow, runDue };

View file

@ -46,10 +46,40 @@ 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;
}
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; }
if (!props || !props.favorite || !props.favorite.id) 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(props.favorite.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").
function parseLicense(html) {
const m = html.match(/"license"\s*:\s*"([^"]+)"/);
return m ? m[1] : null;
}
module.exports = { parseInstances, resolveDownload, downloadProblem, parseLicense };
module.exports = { parseInstances, resolveDownload, downloadProblem, parseLicense, parseCollection, collectionIdFromUrl, designUrl };

View file

@ -503,6 +503,20 @@ async function createProject({ name, description }) {
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 }) {
const { rows } = await db.query(
`UPDATE projects SET name = COALESCE($2, name),
@ -636,7 +650,7 @@ module.exports = {
getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries,
getAllTags, replaceRuleTags, removeTagsFrom, applyAiResult, topTags,
suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion,
getAllProjects, getProject, createProject, updateProject, deleteProject,
getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject,
getSetting, setSetting,
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
createPasswordReset, getValidReset, markResetUsed,

View file

@ -12,7 +12,7 @@ let duplicatePoll = null;
// View preferences, remembered per browser.
const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' };
const PREFS_KEY = 'printhive.prefs';
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai'];
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch'];
// DOM
const fileGrid = document.getElementById('fileGrid');
@ -332,13 +332,14 @@ function scopeTitle() {
if (selectedScope === 'duplicates') return 'Duplicates';
if (selectedScope === 'trash') return 'Trash';
if (selectedScope === 'ai') return 'AI & automatic tags';
if (selectedScope === 'watch') return 'Watched collections';
const p = projects.find((x) => String(x.id) === String(selectedScope));
return p ? p.name : 'Collection';
}
function updateScopeChrome() {
contentTitle.textContent = scopeTitle();
const panel = selectedScope === 'duplicates' || selectedScope === 'ai'; // pages without the model grid
const panel = ['duplicates', 'ai', 'watch'].includes(selectedScope); // pages without the model grid
document.getElementById('filterBar').hidden = panel;
document.querySelector('.view-options').hidden = panel;
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
@ -352,8 +353,10 @@ function renderModels() {
updateScopeChrome();
if (selectedScope === 'duplicates') { clearTimeout(aiPoll); renderDuplicates(); return; }
clearTimeout(duplicatePoll);
if (selectedScope === 'ai') { renderAiPanel(); return; }
if (selectedScope === 'ai') { clearTimeout(watchPoll); renderAiPanel(); return; }
clearTimeout(aiPoll);
if (selectedScope === 'watch') { renderWatchPanel(); return; }
clearTimeout(watchPoll);
const list = getFilteredModels();
resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}${searchNote()}`;
@ -473,6 +476,7 @@ function getFilteredModels() {
function updateCounts() {
document.getElementById('allFilesCount').textContent = models.length;
document.getElementById('aiBusy').hidden = !stats.aiRunning;
document.getElementById('watchCount').textContent = stats.watches ?? 0;
document.getElementById('trashCount').textContent = stats.trash ?? 0;
document.getElementById('duplicatesCount').textContent = stats.duplicateGroups ?? 0;
}
@ -783,6 +787,7 @@ function setupEventListeners() {
fileGrid.addEventListener('submit', (e) => {
if (e.target.id === 'aiStartForm') handleAiStart(e);
if (e.target.id === 'ruleForm') handleAddRule(e);
if (e.target.id === 'watchForm') handleAddWatch(e);
});
fileGrid.addEventListener('change', (e) => {
if (e.target.name === 'aiScope') updateAiEstimate();
@ -1653,6 +1658,8 @@ async function handleAiStart(e) {
async function handleAiPanelClick(e) {
const open = e.target.closest('[data-open-model]');
if (open) { openModelDetail(Number(open.dataset.openModel)); return; }
const watchBtn = e.target.closest('[data-watch]');
if (watchBtn) { handleWatchAction(watchBtn); return; }
const btn = e.target.closest('[data-ai]');
if (!btn) return;
const id = btn.dataset.id;
@ -2060,7 +2067,7 @@ async function handleDownloadFiles() {
const data = await res.json();
if (!res.ok && data.reason === 'makerworld_token') {
resetDownloadFilesBtn(id);
promptMakerworldToken(id, data.error);
promptMakerworldToken(data.error, () => retryModelDownload(id));
return;
}
if (!res.ok) throw new Error(data.error);
@ -2082,11 +2089,13 @@ function resetDownloadFilesBtn(id) {
// ---------------------------------------------------------------- MakerWorld token prompt
// MakerWorld tokens expire. When one is refused, ask for a fresh one right away and
// retry the download with it.
let tokenPromptModelId = null;
// retry what failed — a model's files, or a watched collection.
// retry: async () => ({ ok, message } | { ok: false, reason, error })
let tokenPromptRetry = null;
function promptMakerworldToken(modelId, reason) {
tokenPromptModelId = modelId;
function promptMakerworldToken(reason, retry, action = 'Save & download files') {
tokenPromptRetry = retry;
document.getElementById('tokenSubmit').textContent = action;
document.getElementById('tokenReason').textContent = reason;
document.getElementById('tokenInput').value = '';
const status = document.getElementById('tokenStatus');
@ -2101,7 +2110,7 @@ async function handleTokenSubmit(e) {
e.preventDefault();
const token = document.getElementById('tokenInput').value.trim();
if (!token) return;
const id = tokenPromptModelId;
const retry = tokenPromptRetry;
const status = document.getElementById('tokenStatus');
const submit = document.getElementById('tokenSubmit');
const fail = (text) => {
@ -2111,7 +2120,7 @@ async function handleTokenSubmit(e) {
};
submit.disabled = true;
status.classList.remove('is-error');
status.textContent = 'Saving your token and downloading the files… this can take a minute.';
status.textContent = 'Saving your token and trying again… this can take a minute.';
try {
await postJson('/api/settings', { makerworld_token: token }, 'PUT');
} catch (err) {
@ -2119,25 +2128,186 @@ async function handleTokenSubmit(e) {
return;
}
try {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
fail(data.reason === 'makerworld_token'
const result = await retry();
if (!result.ok) {
fail(result.reason === 'makerworld_token'
? 'MakerWorld still didn’t accept that token. Check you copied the whole value of the token cookie while signed in, then try again.'
: `Your token is saved, but the download failed: ${data.error}`);
: `Your token is saved, but it didn’t work: ${result.error}`);
return;
}
status.textContent = data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.';
await loadData();
setTimeout(() => {
closeModal(tokenModal);
if (fileDetailModal.classList.contains('active') && fileDetailModal.dataset.currentModelId === String(id)) openModelDetail(id);
}, 900);
status.textContent = result.message || 'Token saved.';
setTimeout(() => closeModal(tokenModal), 900);
} catch (err) {
fail(`Your token is saved, but the download failed: ${err.message}`);
fail(`Your token is saved, but it didn’t work: ${err.message}`);
}
}
// Retries a model's file download after a new token; used by the prompt.
async function retryModelDownload(id) {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, reason: data.reason, error: data.error };
await loadData();
if (fileDetailModal.classList.contains('active') && fileDetailModal.dataset.currentModelId === String(id)) openModelDetail(id);
return { ok: true, message: data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.' };
}
// ---------------------------------------------------------------- watched collections
let watchPoll = null;
function watchPanelHtml() {
const intervals = [1, 3, 6, 12, 24, 48]
.map((h) => `<option value="${h}"${h === 6 ? ' selected' : ''}>${h === 1 ? 'every hour' : `every ${h} hours`}</option>`).join('');
return `
<section class="ai-card">
<div class="ai-card-head"><h3>Watch a MakerWorld collection</h3></div>
<p class="form-hint">Printhive checks the collection and imports models added to it, into a collection of the same
name here. Private collections work too, using your MakerWorld token. Models you remove there stay here.</p>
<form id="watchForm" class="watch-form">
<input type="url" id="watchUrl" placeholder="https://makerworld.com/en/collections/…" aria-label="Collection link" required>
<label>Check <select id="watchInterval" aria-label="How often to check">${intervals}</select></label>
<label><input type="checkbox" id="watchAi"> Tag new models with AI</label>
<button type="submit" class="btn btn-primary" id="watchSubmit">Watch collection</button>
</form>
<div id="watchStatus" class="form-hint"></div>
<div id="watchList" class="watch-list"></div>
</section>`;
}
const WATCH_STATUS = { ok: 'Up to date', checking: 'Checking…', error: 'Problem' };
function watchHtml(w) {
const when = w.last_checked_at ? `checked ${formatDate(w.last_checked_at)}` : 'not checked yet';
const listed = w.total != null && w.listed != null && w.listed < w.total
? ` · MakerWorld listed ${w.listed} of ${w.total}` : w.total != null ? ` · ${plural(w.total, 'model')} there` : '';
const recent = (w.recent || []).filter((i) => i.status !== 'existing');
return `
<div class="watch${w.enabled ? '' : ' is-paused'}" data-watch-id="${w.id}">
<div class="watch-head">
<a href="${escapeHtml(w.url)}" target="_blank" rel="noopener">${escapeHtml(w.title || 'MakerWorld collection')}</a>
<span class="pill pill-${w.last_status === 'error' ? 'failed' : w.last_status === 'checking' ? 'running' : 'done'}">
${w.enabled ? (WATCH_STATUS[w.last_status] || 'Waiting') : 'Paused'}</span>
<span class="form-hint">→ ${escapeHtml(w.project_name || 'no collection')}${w.ai_tag ? ' · AI tagging on' : ''}</span>
<span class="watch-actions">
<button type="button" class="btn btn-secondary btn-sm" data-watch="check">Check now</button>
<button type="button" class="btn btn-secondary btn-sm" data-watch="toggle">${w.enabled ? 'Pause' : 'Resume'}</button>
<button type="button" class="btn btn-secondary btn-sm" data-watch="remove">Stop watching</button>
</span>
</div>
<div class="watch-meta">${escapeHtml(when)} · every ${w.interval_hours}h · ${plural(w.imported_count, 'model')} imported${w.error_count ? ` · ${w.error_count} failed` : ''}${listed}</div>
${w.last_error ? `<div class="ai-run-error">${escapeHtml(w.last_error)}
${w.last_error_reason === 'makerworld_token' ? '<button type="button" class="btn btn-primary btn-sm" data-watch="token">Update token</button>' : ''}</div>` : ''}
${recent.length ? `<div class="watch-recent">${recent.map((i) => `
<span>${i.model_id
? `<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.model_name || i.title || `Model ${i.model_id}`)}</button>`
: escapeHtml(i.title || i.design_id)}${i.status === 'error' ? ` <span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>` : ''}</span>`).join('')}</div>` : ''}
</div>`;
}
async function renderWatchPanel() {
fileGrid.className = 'ai-panel';
resultCount.textContent = '';
if (!document.getElementById('watchForm')) fileGrid.innerHTML = watchPanelHtml();
await refreshWatches();
}
async function refreshWatches() {
clearTimeout(watchPoll);
if (selectedScope !== 'watch') return;
let watches;
try {
watches = await getJson('/api/watches');
} catch (err) {
const list = document.getElementById('watchList');
if (list) list.innerHTML = `<p class="form-hint">Couldn’t load watched collections: ${escapeHtml(err.message)}</p>`;
return;
}
const list = document.getElementById('watchList');
if (!list || selectedScope !== 'watch') return;
list.innerHTML = watches.length
? watches.map(watchHtml).join('')
: '<p class="form-hint">Nothing watched yet. Paste a MakerWorld collection link above.</p>';
if (watches.some((w) => w.last_status === 'checking')) watchPoll = setTimeout(refreshWatches, 5000);
}
async function handleAddWatch(e) {
e.preventDefault();
const status = document.getElementById('watchStatus');
const submit = document.getElementById('watchSubmit');
const url = document.getElementById('watchUrl').value.trim();
submit.disabled = true;
status.classList.remove('is-error');
status.textContent = 'Reading that collection…';
try {
const watch = await postJson('/api/watches', {
url,
aiTag: document.getElementById('watchAi').checked,
intervalHours: Number(document.getElementById('watchInterval').value),
});
document.getElementById('watchUrl').value = '';
status.textContent = `Watching “${watch.title}” — importing what's in it now.`;
await loadData();
refreshWatches();
} catch (err) {
status.classList.add('is-error');
status.textContent = err.message;
if (/token/i.test(err.message)) {
promptMakerworldToken(err.message, async () => {
try {
const watch = await postJson('/api/watches', {
url,
aiTag: document.getElementById('watchAi').checked,
intervalHours: Number(document.getElementById('watchInterval').value),
});
await loadData();
refreshWatches();
return { ok: true, message: `Watching “${watch.title}”.` };
} catch (retryErr) {
return { ok: false, reason: /token/i.test(retryErr.message) ? 'makerworld_token' : null, error: retryErr.message };
}
}, 'Save & watch collection');
}
} finally {
submit.disabled = false;
}
}
async function handleWatchAction(btn) {
const id = Number(btn.closest('[data-watch-id]').dataset.watchId);
const action = btn.dataset.watch;
btn.disabled = true;
try {
if (action === 'check') {
await postJson(`/api/watches/${id}/check`);
} else if (action === 'toggle') {
const paused = btn.textContent.trim() === 'Pause';
await postJson(`/api/watches/${id}`, { enabled: !paused }, 'PUT');
} else if (action === 'remove') {
if (!confirm('Stop watching this collection?\n\nThe models it already imported are kept.')) { btn.disabled = false; return; }
await getJson(`/api/watches/${id}`, { method: 'DELETE' });
await loadData();
} else if (action === 'token') {
promptMakerworldToken('MakerWorld didn’t accept your token when checking this collection.',
() => retryWatchCheck(id), 'Save & check collection');
}
} catch (err) {
if (err.message && /token/i.test(err.message)) {
promptMakerworldToken(err.message, () => retryWatchCheck(id), 'Save & check collection');
} else {
alert(err.message);
}
}
refreshWatches();
}
async function retryWatchCheck(id) {
const res = await fetch(`/api/watches/${id}/check`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, reason: data.reason, error: data.error };
refreshWatches();
return { ok: true, message: 'Token saved. Checking the collection now…' };
}
async function handleRefreshMetadata() {
const modelId = fileDetailModal.dataset.currentModelId;
const btn = document.getElementById('refreshLicenseBtn');
@ -2328,7 +2498,8 @@ async function handleImport(e) {
// Details imported, files refused: offer to fix the token and fetch them now.
closeModal(importModal);
openModelDetail(data.id);
promptMakerworldToken(data.id, `Imported “${data.name}”, but its files couldn’t be downloaded: ${data.filesError}`);
promptMakerworldToken(`Imported “${data.name}”, but its files couldn’t be downloaded: ${data.filesError}`,
() => retryModelDownload(data.id));
} else if (data.metadataOnly) {
status.textContent = data.filesError
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`

View file

@ -98,6 +98,10 @@
<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="trash">
<span>Trash</span>
<span class="count" id="trashCount">0</span>

View file

@ -452,3 +452,18 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.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; }
.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); }

View file

@ -26,6 +26,7 @@ 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 embeddings = require('./lib/embeddings');
@ -792,22 +793,21 @@ function removeDerivedFiles(fileIds, entryIds) {
// 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 url = (req.body.url || '').trim();
const projects = req.body.projects || [];
// 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.
async function importFromUrl(url, { projects = [] } = {}) {
const detected = generic.detectSite(url);
if (!detected) return res.status(400).json({ error: 'That does not look like a valid URL.' });
if (!detected) throw Object.assign(new Error('That does not look like a valid URL.'), { status: 400 });
if (detected.site === 'thingiverse') {
const model = await importThingiverse(url, projects);
return res.status(201).json(model);
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 res.status(409).json({ error: 'Already imported', model: publicModel(existing) });
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.
@ -875,7 +875,22 @@ app.post('/api/import', async (req, res, next) => {
files,
});
fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched, filesError, filesErrorReason });
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,
});
} catch (e) { next(e); }
});
@ -1120,7 +1135,9 @@ app.get('/api/library/stats', async (req, res, next) => {
const [trash, groups, pending, thumbnailsPending, tagsPending, aiRunning] = await Promise.all([
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount(),
autotag.pendingCount(), aiRuns.hasActiveRun()]);
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending, tagsPending, aiRunning });
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); }
});
@ -1277,6 +1294,62 @@ app.post('/api/ai/runs/:id/resume', async (req, res, next) => {
} 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.
async function fetchMakerworldCollection(url) {
const { token, flareUrl } = await requireMakerworldSettings();
const html = await flaresolverr.solve(flareUrl, url, {
cookies: [{ name: 'token', value: token, domain: '.makerworld.com' }],
});
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' });
}
return collection;
}
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 } = req.body || {};
res.status(201).json(await collectionWatch.add({ url: String(url || '').trim(), aiTag, intervalHours }));
} 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() : '';
@ -1376,6 +1449,12 @@ db.init()
.then(() => {
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
fingerprint.kick(); // backfill fingerprints, tags and the search index
collectionWatch.init({
fetchCollection: fetchMakerworldCollection,
importFromUrl,
startAiRun: (modelIds) => aiRuns.createRun(modelIds, { describe: true }),
});
collectionWatch.start();
return aiRuns.init(resolveAiConfig);
})
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });

View file

@ -28,3 +28,39 @@ test('a download answer without a link says why, and flags a refused token', ()
{ 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);
});
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);
});