Compare commits

..

3 commits

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:17:46 +10:00
7 changed files with 215 additions and 16 deletions

View file

@ -8,7 +8,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **Import from URL**:
- **Thingiverse** — downloads files automatically via the official API (needs a free app token).
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token.
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. If files can't be downloaded the import says why, and when MakerWorld refuses the token (they expire) Printhive asks for a fresh one and retries.
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects.
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden.

View file

@ -35,10 +35,21 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) {
});
}
// Why a download-link answer came back without a link: { message, tokenRejected },
// or null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."}
function downloadProblem(answer) {
if (answer && answer.url) return null;
const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim();
if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) {
return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true };
}
return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false };
}
// 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, parseLicense };
module.exports = { parseInstances, resolveDownload, downloadProblem, parseLicense };

View file

@ -30,6 +30,7 @@ const setupModal = document.getElementById('setupModal');
const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal');
const slicerModal = document.getElementById('slicerModal');
const tokenModal = document.getElementById('tokenModal');
document.addEventListener('DOMContentLoaded', () => {
loadPrefs();
@ -767,6 +768,8 @@ function setupEventListeners() {
});
syncSearchMode();
document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel);
document.getElementById('tokenForm').addEventListener('submit', handleTokenSubmit);
document.getElementById('cancelToken').addEventListener('click', () => closeModal(tokenModal));
document.getElementById('detailRenamed').addEventListener('click', (e) => {
if (e.target.closest('#undoRenameBtn')) handleUndoRename();
});
@ -2055,13 +2058,83 @@ async function handleDownloadFiles() {
try {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json();
if (!res.ok && data.reason === 'makerworld_token') {
resetDownloadFilesBtn(id);
promptMakerworldToken(id, data.error);
return;
}
if (!res.ok) throw new Error(data.error);
await loadData();
openModelDetail(id);
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
} catch (err) {
alert('Download failed: ' + err.message);
resetDownloadFilesBtn(id);
}
}
function resetDownloadFilesBtn(id) {
const btn = document.getElementById('downloadFilesBtn');
const model = findModel(id);
btn.disabled = false;
btn.textContent = model && model.files.length ? 'Re-check source for files' : 'Download files from source';
}
// ---------------------------------------------------------------- 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;
function promptMakerworldToken(modelId, reason) {
tokenPromptModelId = modelId;
document.getElementById('tokenReason').textContent = reason;
document.getElementById('tokenInput').value = '';
const status = document.getElementById('tokenStatus');
status.textContent = '';
status.classList.remove('is-error');
document.getElementById('tokenSubmit').disabled = false;
tokenModal.classList.add('active');
document.getElementById('tokenInput').focus();
}
async function handleTokenSubmit(e) {
e.preventDefault();
const token = document.getElementById('tokenInput').value.trim();
if (!token) return;
const id = tokenPromptModelId;
const status = document.getElementById('tokenStatus');
const submit = document.getElementById('tokenSubmit');
const fail = (text) => {
status.classList.add('is-error');
status.textContent = text;
submit.disabled = false;
};
submit.disabled = true;
status.classList.remove('is-error');
status.textContent = 'Saving your token and downloading the files… this can take a minute.';
try {
await postJson('/api/settings', { makerworld_token: token }, 'PUT');
} catch (err) {
fail(`Couldn’t save the token: ${err.message}`);
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'
? '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}`);
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);
} catch (err) {
fail(`Your token is saved, but the download failed: ${err.message}`);
}
}
@ -2251,11 +2324,19 @@ async function handleImport(e) {
const data = await res.json();
if (res.ok) {
await loadData();
if (data.metadataOnly) {
status.textContent = data.metadataFetched
if (data.metadataOnly && data.filesErrorReason === 'makerworld_token') {
// 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}`);
} else if (data.metadataOnly) {
status.textContent = data.filesError
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`
: data.metadataFetched
? `Imported "${data.name}". Opening it so you can add the downloaded files…`
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 1200);
// Give a download problem time to be read before moving on.
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, data.filesError ? 6000 : 1200);
} else {
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
setTimeout(() => closeModal(importModal), 900);

View file

@ -320,8 +320,10 @@
placeholder="Bearer token from your logged-in session" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="makerworld_token">—</span>.
Lets Printhive download files from MakerWorld using your account. Paste the bearer
token from your logged-in browser session (see instructions in chat).
Lets Printhive download files from MakerWorld using your account. In a browser signed in to
makerworld.com, open the developer tools (Application / Storage → Cookies → makerworld.com)
and copy the value of the <code>token</code> cookie. It expires after a while: paste a new one if
MakerWorld downloads stop working.
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button>
</div>
@ -547,6 +549,36 @@
</div>
</div>
<!-- MakerWorld token prompt (shown when MakerWorld refuses the saved token) -->
<div id="tokenModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Update your MakerWorld token</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="tokenForm">
<p id="tokenReason" class="token-reason"></p>
<ol class="token-steps">
<li>In this browser, sign in to <a href="https://makerworld.com" target="_blank" rel="noopener">makerworld.com</a>.</li>
<li>Open the developer tools (<kbd>⌥⌘I</kbd> on a Mac, <kbd>F12</kbd> elsewhere) and go to
Application (Storage in Firefox) → Cookies → https://makerworld.com.</li>
<li>Copy the value of the <code>token</code> cookie and paste it below.</li>
</ol>
<div class="form-group">
<label for="tokenInput">MakerWorld auth token</label>
<input type="password" id="tokenInput" autocomplete="off" spellcheck="false" required>
</div>
<div id="tokenStatus" class="form-hint token-status" role="status"></div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelToken">Not now</button>
<button type="submit" class="btn btn-primary" id="tokenSubmit">Save &amp; download files</button>
</div>
</form>
</div>
</div>
</div>
<!-- Slicer choice (per browser) -->
<div id="slicerModal" class="modal">
<div class="modal-content">

View file

@ -447,3 +447,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.suggestion-actions .btn-sm { align-self: auto; }
.suggestion-models { font-size: 13px; color: var(--text-muted); line-height: 1.7; }
.suggested-collections { font-size: 12px; color: var(--accent); }
.token-reason { font-size: 14px; color: var(--text); line-height: 1.5; margin-bottom: 12px; }
.token-steps { margin: 0 0 16px 18px; font-size: 13px; color: var(--text-muted); line-height: 1.7; }
.token-steps code, .token-steps kbd { font-family: var(--mono); font-size: 12px; }
.token-status { min-height: 1em; }
.token-status.is-error { color: var(--danger); }

View file

@ -444,6 +444,7 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
// original name is already present.
async function fetchSourceFiles(model, skipNames) {
if (model.source_site === 'makerworld') {
await requireMakerworldSettings(); // before the page fetch, which fails without FlareSolverr
const html = await fetchPageHtml(model.source_url);
return downloadMakerworldFiles(html, skipNames);
}
@ -828,11 +829,15 @@ app.post('/api/import', async (req, res, next) => {
// Download the actual files where we can (MakerWorld via FlareSolverr+token,
// Printables via its public GraphQL API), and capture the license.
let files = [];
let filesError = null; // why no files could be downloaded, shown after the import
let filesErrorReason = null; // makerworld_token: the app offers to update the token
let license = meta.license || null;
let commercial = null;
if (detected.site === 'makerworld' && html) {
files = await downloadMakerworldFiles(html).catch((err) => {
console.warn('MakerWorld file download failed:', err.message);
filesError = err.message;
filesErrorReason = err.reason || null;
return [];
});
license = makerworld.parseLicense(html) || license;
@ -840,6 +845,7 @@ app.post('/api/import', async (req, res, next) => {
} else if (detected.site === 'printables' && detected.id) {
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
console.warn('Printables file download failed:', err.message);
filesError = err.message;
return { files: [] };
});
files = result.files;
@ -869,7 +875,7 @@ app.post('/api/import', async (req, res, next) => {
files,
});
fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched, filesError, filesErrorReason });
} catch (e) { next(e); }
});
@ -918,14 +924,33 @@ async function resolveCommercial(licenseName, fallback) {
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
// skipNames: original filenames already present (so a re-download only adds new ones).
async function downloadMakerworldFiles(html, skipNames = new Set()) {
// MakerWorld downloads need both a FlareSolverr address and your auth token.
// Errors carry a reason the app can act on: makerworld_token opens the token prompt.
async function requireMakerworldSettings() {
const { value: token } = await resolveSetting('makerworld_token');
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
if (!token || !flareUrl) return [];
if (!flareUrl) {
throw Object.assign(new Error('Add your FlareSolverr URL in Settings to download MakerWorld files (MakerWorld is behind Cloudflare).'),
{ status: 400, reason: 'makerworld_settings' });
}
if (!token) {
throw Object.assign(new Error('Add your MakerWorld auth token to download MakerWorld files.'), { status: 400, reason: 'makerworld_token' });
}
return { token, flareUrl };
}
// Downloads a MakerWorld model's files. When nothing could be downloaded it throws
// with the reason (missing settings, an expired token, …) so the import can say why.
async function downloadMakerworldFiles(html, skipNames = new Set()) {
const { token, flareUrl } = await requireMakerworldSettings();
const instances = makerworld.parseInstances(html);
if (!instances.length) {
throw Object.assign(new Error('Couldn’t find any printable files on the MakerWorld page.'), { status: 502 });
}
ensureUploadDir();
const files = [];
const problems = [];
for (let i = 0; i < instances.length; i++) {
if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls
const inst = instances[i];
@ -934,18 +959,33 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
} catch (err) {
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
problems.push({ message: `Couldn’t ask MakerWorld for a download link: ${err.message}` });
continue;
}
const problem = makerworld.downloadProblem(dl);
if (problem) {
console.warn(`MakerWorld instance ${inst.id}: ${problem.message}`);
problems.push(problem);
continue;
}
if (!dl || !dl.url) continue;
const original = dl.name || `instance-${inst.id}.3mf`;
if (skipNames.has(original)) continue;
const resp = await fetch(dl.url);
if (!resp.ok) { console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); continue; }
if (!resp.ok) {
console.warn(`MakerWorld CDN download ${resp.status} for ${original}`);
problems.push({ message: `Downloading ${original} from MakerWorld failed (HTTP ${resp.status})` });
continue;
}
const fileName = uniqueName(original);
const filePath = path.join(UPLOAD_DIR, fileName);
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
files.push(describeFile(filePath, fileName, original));
}
if (!files.length && problems.length) {
const refused = problems.find((p) => p.tokenRejected);
throw Object.assign(new Error((refused || problems[0]).message),
{ status: 502, reason: refused ? 'makerworld_token' : undefined });
}
return files;
}
@ -1329,7 +1369,7 @@ app.post('/api/autotag/reapply', async (req, res, next) => {
// Central error handler.
app.use((err, req, res, next) => {
if (!err.status) console.error('Request error:', err.message);
res.status(err.status || 500).json({ error: err.message });
res.status(err.status || 500).json({ error: err.message, ...(err.reason ? { reason: err.reason } : {}) });
});
db.init()

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

@ -0,0 +1,30 @@
// MakerWorld importer helpers (lib/importers/makerworld.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const makerworld = require('../lib/importers/makerworld');
const page = (design) =>
`<html><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { design } } })}</script></html>`;
test('printable instances and the license are read from the page data', () => {
const html = page({
license: 'Standard Digital File License',
instances: [{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' }, { id: 650089 }],
});
assert.deepEqual(makerworld.parseInstances(html), [
{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' },
{ id: 650089, title: '' },
]);
assert.equal(makerworld.parseLicense(html), 'Standard Digital File License');
assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []);
});
test('a download answer without a link says why, and flags a refused token', () => {
assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null);
const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' });
assert.equal(refused.tokenRejected, true);
assert.match(refused.message, /token \(it has probably expired\)/);
assert.deepEqual(makerworld.downloadProblem({ code: 7, error: 'Model not found' }),
{ message: 'MakerWorld didn’t give a download link: Model not found', tokenRejected: false });
assert.deepEqual(makerworld.downloadProblem(null), { message: 'MakerWorld didn’t give a download link', tokenRejected: false });
});