Compare commits

..

2 commits

Author SHA1 Message Date
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
5 changed files with 25 additions and 16 deletions

View file

@ -23,7 +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. - **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. MakerWorld only lets an account download a couple of files in a row before its anti-bot limit refuses the rest, so most models in a big batch arrive as metadata: those are listed as still without files, with a "Get files" button to fetch them a few at a time. - **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: MakerWorld only lets an account download a couple in a row, so models arrive listed as still without files and you fetch the ones you want with "Get files".
- **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. - **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. - **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. - **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

@ -255,7 +255,7 @@ CREATE TABLE IF NOT EXISTS watched_collection_items (
design_id TEXT NOT NULL, design_id TEXT NOT NULL,
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL, model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, -- imported | existing | error status TEXT NOT NULL, -- imported | partial (no files yet) | existing | error
error TEXT, error TEXT,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (watch_id, design_id) PRIMARY KEY (watch_id, design_id)

View file

@ -162,17 +162,19 @@ async function importFound(watch, collection) {
if (!live || !live.enabled) break; if (!live || !live.enabled) break;
const url = makerworld.designUrl(design); const url = makerworld.designUrl(design);
try { try {
const result = await deps.importFromUrl(url, { projects: watch.project_id ? [watch.project_id] : [] }); const result = await deps.importFromUrl(url, {
projects: watch.project_id ? [watch.project_id] : [],
withFiles: false, // MakerWorld allows only a couple of downloads in a row
});
if (result.existing) { if (result.existing) {
if (watch.project_id) await store.addModelToProject(result.model.id, watch.project_id); if (watch.project_id) await store.addModelToProject(result.model.id, watch.project_id);
await recordItem(watch.id, design, result.model.id, 'existing', null); await recordItem(watch.id, design, result.model.id, 'existing', null);
} else { } else {
added += 1; added += 1;
fresh.push(result.model.id); fresh.push(result.model.id);
// Imported, but MakerWorld wouldn't hand over the files: say so rather // A check brings in the page, not the files, so every new model starts
// than letting an empty model look like a success. // 'partial' — "Get files" on the ones you want fills them in.
await recordItem(watch.id, design, result.model.id, await recordItem(watch.id, design, result.model.id, 'partial', result.filesError || null);
result.filesError ? 'partial' : 'imported', result.filesError || null);
} }
// A refused token means every later download fails too: stop and say so. // A refused token means every later download fails too: stop and say so.
if (result.filesErrorReason === 'makerworld_token') { if (result.filesErrorReason === 'makerworld_token') {

View file

@ -2162,7 +2162,9 @@ function watchPanelHtml() {
<section class="ai-card"> <section class="ai-card">
<div class="ai-card-head"><h3>Watch a MakerWorld collection</h3></div> <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 <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> name here. Private collections work too, using your MakerWorld token. Models you remove there stay here.
A check brings in each model's page — name, description, picture, licence — but not its files, because MakerWorld
only allows a couple of downloads in a row; use “Get files” on the ones you want.</p>
<form id="watchForm" class="watch-form"> <form id="watchForm" class="watch-form">
<input type="url" id="watchUrl" placeholder="https://makerworld.com/en/collections/…" aria-label="Collection link" required> <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>Check <select id="watchInterval" aria-label="How often to check">${intervals}</select></label>
@ -2200,7 +2202,7 @@ function watchHtml(w) {
${recent.length ? `<div class="watch-recent">${recent.map((i) => ` ${recent.length ? `<div class="watch-recent">${recent.map((i) => `
<span>${i.model_id <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>` ? `<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.error ? ` <span class="ai-run-error">${escapeHtml(i.error)}</span>` : ''}${i.error && i.model_id : escapeHtml(i.title || i.design_id)}${i.error ? ` <span class="ai-run-error">${escapeHtml(i.error)}</span>` : ''}${(i.error || i.status === 'partial') && i.model_id
? ` <button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button>` : ''}</span>`).join('')}</div>` : ''} ? ` <button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button>` : ''}</span>`).join('')}</div>` : ''}
</div>`; </div>`;
} }

View file

@ -799,7 +799,10 @@ function removeDerivedFiles(fileIds, entryIds) {
// Imports one model URL: its details, its files where they can be downloaded, and // Imports one model URL: its details, its files where they can be downloaded, and
// its license. Returns { model, existing, metadataFetched, filesError, filesErrorReason }. // its license. Returns { model, existing, metadataFetched, filesError, filesErrorReason }.
// Used by the import endpoint and by watched collections. // Used by the import endpoint and by watched collections.
async function importFromUrl(url, { projects = [] } = {}) { // 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); const detected = generic.detectSite(url);
if (!detected) throw Object.assign(new Error('That does not look like a valid URL.'), { status: 400 }); if (!detected) throw Object.assign(new Error('That does not look like a valid URL.'), { status: 400 });
@ -837,12 +840,14 @@ async function importFromUrl(url, { projects = [] } = {}) {
let license = meta.license || null; let license = meta.license || null;
let commercial = null; let commercial = null;
if (detected.site === 'makerworld' && html) { if (detected.site === 'makerworld' && html) {
files = await downloadMakerworldFiles(html).catch((err) => { if (withFiles) {
console.warn('MakerWorld file download failed:', err.message); files = await downloadMakerworldFiles(html).catch((err) => {
filesError = err.message; console.warn('MakerWorld file download failed:', err.message);
filesErrorReason = err.reason || null; filesError = err.message;
return []; filesErrorReason = err.reason || null;
}); return [];
});
}
license = makerworld.parseLicense(html) || license; license = makerworld.parseLicense(html) || license;
commercial = await resolveCommercial(license); commercial = await resolveCommercial(license);
} else if (detected.site === 'printables' && detected.id) { } else if (detected.site === 'printables' && detected.id) {