From 28a95ee3303915c255e28632fe4c66460d9a356b Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Tue, 15 Sep 2026 18:17:46 +1000 Subject: [PATCH 1/2] 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 --- lib/importers/makerworld.js | 13 ++++++++++++- public/app.js | 11 +++++++---- public/index.html | 6 ++++-- server.js | 39 ++++++++++++++++++++++++++++++++----- test/makerworld.test.js | 27 +++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 test/makerworld.test.js diff --git a/lib/importers/makerworld.js b/lib/importers/makerworld.js index d056310..a81eed2 100644 --- a/lib/importers/makerworld.js +++ b/lib/importers/makerworld.js @@ -35,10 +35,21 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) { }); } +// Why a download-link answer came back without a link, in words you can act on; +// 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 'MakerWorld didn’t accept your auth token (it has probably expired). Paste a fresh one in Settings → MakerWorld auth token, then use “Download files from source”.'; + } + return `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`; +} + // 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 }; diff --git a/public/app.js b/public/app.js index 56fd91a..72da659 100644 --- a/public/app.js +++ b/public/app.js @@ -2252,10 +2252,13 @@ async function handleImport(e) { if (res.ok) { await loadData(); if (data.metadataOnly) { - status.textContent = 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); + 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…`; + // 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); diff --git a/public/index.html b/public/index.html index 8487601..23dc9ce 100644 --- a/public/index.html +++ b/public/index.html @@ -320,8 +320,10 @@ placeholder="Bearer token from your logged-in session" autocomplete="off">

Status: —. - 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 token cookie. It expires after a while: paste a new one if + MakerWorld downloads stop working.

diff --git a/server.js b/server.js index eb18843..ea69ea8 100644 --- a/server.js +++ b/server.js @@ -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,13 @@ 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 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; return []; }); license = makerworld.parseLicense(html) || license; @@ -840,6 +843,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 +873,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 }); } catch (e) { next(e); } }); @@ -918,14 +922,28 @@ 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. +async function requireMakerworldSettings() { const { value: token } = await resolveSetting('makerworld_token'); const { value: flareUrl } = await resolveSetting('flaresolverr_url'); - if (!token || !flareUrl) return []; + if (!token || !flareUrl) { + throw Object.assign(new Error('Add your MakerWorld auth token and FlareSolverr URL in Settings to download MakerWorld files.'), { status: 400 }); + } + 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 +952,29 @@ 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(`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}`); + 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(`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) throw Object.assign(new Error(problems[0]), { status: 502 }); return files; } diff --git a/test/makerworld.test.js b/test/makerworld.test.js new file mode 100644 index 0000000..7795a7c --- /dev/null +++ b/test/makerworld.test.js @@ -0,0 +1,27 @@ +// 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) => + ``; + +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('Just a moment...'), []); +}); + +test('a download answer without a link says why', () => { + assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null); + assert.match(makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' }), /token \(it has probably expired\)/); + assert.equal(makerworld.downloadProblem({ code: 7, error: 'Model not found' }), 'MakerWorld didn’t give a download link: Model not found'); + assert.equal(makerworld.downloadProblem(null), 'MakerWorld didn’t give a download link'); +}); From 4a88d66e90f463bc920ea0aa26e8129dfd622248 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Tue, 15 Sep 2026 18:33:10 +1000 Subject: [PATCH 2/2] 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 --- README.md | 2 +- lib/importers/makerworld.js | 8 ++-- public/app.js | 82 ++++++++++++++++++++++++++++++++++++- public/index.html | 30 ++++++++++++++ public/styles.css | 5 +++ server.js | 27 ++++++++---- test/makerworld.test.js | 11 +++-- 7 files changed, 146 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6ffa66d..a1d61df 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/importers/makerworld.js b/lib/importers/makerworld.js index a81eed2..a4f32e4 100644 --- a/lib/importers/makerworld.js +++ b/lib/importers/makerworld.js @@ -35,15 +35,15 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) { }); } -// Why a download-link answer came back without a link, in words you can act on; -// null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."} +// 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 'MakerWorld didn’t accept your auth token (it has probably expired). Paste a fresh one in Settings → MakerWorld auth token, then use “Download files from source”.'; + return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true }; } - return `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`; + 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"). diff --git a/public/app.js b/public/app.js index 72da659..6b5075c 100644 --- a/public/app.js +++ b/public/app.js @@ -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); - btn.disabled = false; + 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,7 +2324,12 @@ async function handleImport(e) { const data = await res.json(); if (res.ok) { await loadData(); - if (data.metadataOnly) { + 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 diff --git a/public/index.html b/public/index.html index 23dc9ce..31198e7 100644 --- a/public/index.html +++ b/public/index.html @@ -549,6 +549,36 @@ + + +