mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: import author details from makerworld
This commit is contained in:
1 parent
f04ca0c8b8
commit
b16c27bc1e
13 files changed
+359
-20
No files matched your search
@@ -0,0 +1,29 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Print" ADD COLUMN "authorId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Author" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"handle" TEXT,
|
||||
"bio" TEXT,
|
||||
"bioTranslated" TEXT,
|
||||
"links" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"avatarUrl" TEXT,
|
||||
"backgroundUrl" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Author_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Author_provider_externalId_key" ON "Author"("provider", "externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Print_authorId_idx" ON "Print"("authorId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Print" ADD CONSTRAINT "Print_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -33,6 +33,8 @@ model Print {
|
||||
tags String[] @default([])
|
||||
folderId String?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
authorId String?
|
||||
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
sourcePath String?
|
||||
plates Plate[]
|
||||
files PrintFile[] @relation("PrintFiles")
|
||||
@@ -45,6 +47,30 @@ model Print {
|
||||
@@unique([folderId, nameNormalized])
|
||||
@@index([folderId, name])
|
||||
@@index([tags], type: Gin)
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
/// A creator/designer on some source platform (MakerWorld today; Printables/Thingiverse etc.
|
||||
/// later). `id` is provider-prefixed ("makerworld:3582000380") rather than a generated cuid so
|
||||
/// re-importing a model from the same author upserts the same row instead of creating a
|
||||
/// duplicate. Print.creator stays as the plain display string for backward compatibility and
|
||||
/// non-provider sources; this table is the richer record clickable author pages will use.
|
||||
model Author {
|
||||
id String @id
|
||||
provider String
|
||||
externalId String
|
||||
name String?
|
||||
handle String?
|
||||
bio String?
|
||||
bioTranslated String?
|
||||
links String[] @default([])
|
||||
avatarUrl String?
|
||||
backgroundUrl String?
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([provider, externalId])
|
||||
}
|
||||
|
||||
model Plate {
|
||||
|
||||
+32
-1
@@ -1,8 +1,36 @@
|
||||
import fs from "node:fs";
|
||||
import type { Folder, Plate, Print, PrintFile } from "@prisma/client";
|
||||
import type { Author, Folder, Plate, Print, PrintFile } from "@prisma/client";
|
||||
import { plateThumbExists, plateThumbPath } from "./services/printService";
|
||||
import { preparedFilename } from "./services/preparedPrint";
|
||||
|
||||
export type AuthorOut = {
|
||||
id: string;
|
||||
provider: string;
|
||||
external_id: string;
|
||||
name: string | null;
|
||||
handle: string | null;
|
||||
bio: string | null;
|
||||
bio_translated: string | null;
|
||||
links: string[];
|
||||
avatar_url: string | null;
|
||||
background_url: string | null;
|
||||
};
|
||||
|
||||
export function toAuthorOut(author: Author): AuthorOut {
|
||||
return {
|
||||
id: author.id,
|
||||
provider: author.provider,
|
||||
external_id: author.externalId,
|
||||
name: author.name,
|
||||
handle: author.handle,
|
||||
bio: author.bio,
|
||||
bio_translated: author.bioTranslated,
|
||||
links: author.links,
|
||||
avatar_url: author.avatarUrl,
|
||||
background_url: author.backgroundUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export type PreparedPrintOut = {
|
||||
printer?: string | null;
|
||||
material?: string | null;
|
||||
@@ -38,6 +66,7 @@ export type PrintOut = {
|
||||
title: string | null;
|
||||
notes: string | null;
|
||||
creator: string | null;
|
||||
author: AuthorOut | null;
|
||||
collection: string | null;
|
||||
tags: string[];
|
||||
folder_id: string | null;
|
||||
@@ -103,6 +132,7 @@ export function toPrintOut(
|
||||
plates: Plate[],
|
||||
files: PrintFile[],
|
||||
preparedFile: PrintFile | null,
|
||||
author?: Author | null,
|
||||
): PrintOut {
|
||||
const sortedPlates = plates.toSorted((a, b) => a.position - b.position);
|
||||
const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p));
|
||||
@@ -149,6 +179,7 @@ export function toPrintOut(
|
||||
title: print.title,
|
||||
notes: print.notes,
|
||||
creator: print.creator,
|
||||
author: author ? toAuthorOut(author) : null,
|
||||
collection: print.collection,
|
||||
tags: print.tags,
|
||||
folder_id: print.folderId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { downloadImportToTemp, importPrintFromUrl, inspectImportLink, type ImportRequestBody } from "../services/importService";
|
||||
import { resolveMakerworldCookie } from "../services/importResolvers";
|
||||
import { upsertAuthorFromImport } from "../services/authorService";
|
||||
import { extractMakerworldBearerToken } from "../services/makerworldCloudApi";
|
||||
import {
|
||||
fetchMakerworldCollectionEntries,
|
||||
@@ -37,8 +38,8 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const { print, plates } = await importPrintFromUrl(url, body);
|
||||
res.json(toPrintOut(print, plates, [], null));
|
||||
const { print, plates, author } = await importPrintFromUrl(url, body);
|
||||
res.json(toPrintOut(print, plates, [], null, author));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -78,15 +79,18 @@ router.post(
|
||||
const { tempPath, filename, meta } = await downloadImportToTemp(url, body);
|
||||
try {
|
||||
if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip");
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
const { prints, failed } = await extractZipEntriesToPrints(tempPath, body.entries, {
|
||||
title: body.title ?? meta.title,
|
||||
notes: body.notes ?? meta.description,
|
||||
tags: body.tags && body.tags.length ? body.tags : meta.tags,
|
||||
folderId: body.folder_id,
|
||||
creator: meta.creator,
|
||||
authorId: author?.id ?? null,
|
||||
previewImageUrl: meta.previewImageUrl,
|
||||
galleryImages: meta.galleryImages,
|
||||
});
|
||||
res.json({ prints: prints.map((p) => toPrintOut(p, p.plates, [], null)), failed });
|
||||
res.json({ prints: prints.map((p) => toPrintOut(p, p.plates, [], null, author)), failed });
|
||||
} finally {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
@@ -154,8 +158,8 @@ router.post(
|
||||
thingiverse_cookie: body.thingiverse_cookie,
|
||||
};
|
||||
try {
|
||||
const { print, plates } = await importPrintFromUrl(modelUrl, itemBody);
|
||||
return { ok: true as const, print: toPrintOut(print, plates, [], null) };
|
||||
const { print, plates, author } = await importPrintFromUrl(modelUrl, itemBody);
|
||||
return { ok: true as const, print: toPrintOut(print, plates, [], null, author) };
|
||||
} catch {
|
||||
return { ok: false as const, designId };
|
||||
}
|
||||
|
||||
@@ -115,7 +115,10 @@ router.get(
|
||||
}
|
||||
|
||||
const where = buildPrintWhere(req);
|
||||
const prints = await prisma.print.findMany({ where, include: { plates: { orderBy: { position: "asc" } } } });
|
||||
const prints = await prisma.print.findMany({
|
||||
where,
|
||||
include: { plates: { orderBy: { position: "asc" } }, author: true },
|
||||
});
|
||||
const printIds = prints.map((p) => p.id);
|
||||
const files = printIds.length ? await prisma.printFile.findMany({ where: { printId: { in: printIds } } }) : [];
|
||||
const filesByPrint = new Map<string, typeof files>();
|
||||
@@ -147,7 +150,7 @@ router.get(
|
||||
const out = paged.map((p) => {
|
||||
const printFiles = filesByPrint.get(p.id) ?? [];
|
||||
const preparedFile = p.preparedFileId ? printFiles.find((f) => f.id === p.preparedFileId) ?? null : null;
|
||||
return toPrintOut(p, p.plates, printFiles, preparedFile);
|
||||
return toPrintOut(p, p.plates, printFiles, preparedFile, p.author);
|
||||
});
|
||||
res.json(out);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { prisma } from "../db";
|
||||
import type { ImportedAuthorInfo } from "./importResolvers";
|
||||
import type { Author } from "@prisma/client";
|
||||
|
||||
export function buildAuthorId(provider: string, externalId: string): string {
|
||||
return `${provider}:${externalId}`;
|
||||
}
|
||||
|
||||
/** Upserts an Author row from resolved import metadata and returns the full record (not just
|
||||
* its id) so callers can build an immediate API response without a second fetch. Always
|
||||
* refreshes the stored fields (bio/avatar/etc. can change between imports), so re-importing a
|
||||
* model from an already-known author keeps that author's record current rather than stale from
|
||||
* first import. Never throws -- a broken author fetch shouldn't fail the print import itself. */
|
||||
export async function upsertAuthorFromImport(info: ImportedAuthorInfo | null): Promise<Author | null> {
|
||||
if (!info) return null;
|
||||
const id = buildAuthorId(info.provider, info.externalId);
|
||||
try {
|
||||
return await prisma.author.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
provider: info.provider,
|
||||
externalId: info.externalId,
|
||||
name: info.name,
|
||||
handle: info.handle,
|
||||
bio: info.bio,
|
||||
bioTranslated: info.bioTranslated,
|
||||
links: info.links,
|
||||
avatarUrl: info.avatarUrl,
|
||||
backgroundUrl: info.backgroundUrl,
|
||||
},
|
||||
update: {
|
||||
name: info.name,
|
||||
handle: info.handle,
|
||||
bio: info.bio,
|
||||
bioTranslated: info.bioTranslated,
|
||||
links: info.links,
|
||||
avatarUrl: info.avatarUrl,
|
||||
backgroundUrl: info.backgroundUrl,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -526,10 +526,38 @@ export type ImportedPageMetadata = {
|
||||
* (e.g. from the MakerWorld cloud API's own response) rather than needing to guess one from
|
||||
* the download URL's path or a Content-Disposition header. */
|
||||
filename: string | null;
|
||||
/** The page's photo gallery, if any -- distinct from previewImageUrl (the single cover
|
||||
* image used as the plate thumbnail). Everything here is meant to be attached as supporting
|
||||
* files instead. */
|
||||
galleryImages: { url: string; filename: string }[];
|
||||
/** The richer, structured creator record (for the Author table) -- distinct from `creator`,
|
||||
* which stays a plain display string for backward compatibility and non-provider sources. */
|
||||
author: ImportedAuthorInfo | null;
|
||||
};
|
||||
|
||||
export type ImportedAuthorInfo = {
|
||||
provider: string;
|
||||
externalId: string;
|
||||
name: string | null;
|
||||
handle: string | null;
|
||||
bio: string | null;
|
||||
bioTranslated: string | null;
|
||||
links: string[];
|
||||
avatarUrl: string | null;
|
||||
backgroundUrl: string | null;
|
||||
};
|
||||
|
||||
export function emptyImportedPageMetadata(): ImportedPageMetadata {
|
||||
return { title: null, tags: [], description: null, creator: null, previewImageUrl: null, filename: null };
|
||||
return {
|
||||
title: null,
|
||||
tags: [],
|
||||
description: null,
|
||||
creator: null,
|
||||
previewImageUrl: null,
|
||||
filename: null,
|
||||
galleryImages: [],
|
||||
author: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort metadata for a landing page, used to fill in the Print when the caller didn't
|
||||
|
||||
@@ -36,9 +36,11 @@ import {
|
||||
parseMakerworldModelUrl,
|
||||
resolveMakerworldViaCloudApi,
|
||||
} from "./makerworldCloudApi";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { createPrint, type PrintMetaInput } from "./printCreation";
|
||||
import { plateThumbExists, saveThumbFromBytes } from "./printService";
|
||||
import type { Plate, Print } from "@prisma/client";
|
||||
import { saveFileFromTemp } from "./printFileService";
|
||||
import type { Author, Plate, Print } from "@prisma/client";
|
||||
|
||||
export type ImportRequestBody = ImportCookies & {
|
||||
url: string;
|
||||
@@ -229,6 +231,8 @@ export async function openImportResponse(
|
||||
creator: extracted.creator ?? inheritedMeta.creator,
|
||||
previewImageUrl: extracted.previewImageUrl ?? inheritedMeta.previewImageUrl,
|
||||
filename: extracted.filename ?? inheritedMeta.filename,
|
||||
galleryImages: extracted.galleryImages.length ? extracted.galleryImages : inheritedMeta.galleryImages,
|
||||
author: extracted.author ?? inheritedMeta.author,
|
||||
};
|
||||
if (pageHost.endsWith("makerworld.com")) {
|
||||
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
|
||||
@@ -353,23 +357,69 @@ export async function applyCoverThumbnailIfMissing(
|
||||
}
|
||||
}
|
||||
|
||||
const GALLERY_IMAGE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const GALLERY_IMAGE_MAX_COUNT = 20;
|
||||
|
||||
/** Best-effort: downloads a resolved page's remaining gallery photos -- everything besides
|
||||
* whichever one was already used as the plate thumbnail (skipUrl) -- and attaches each as a
|
||||
* supporting file. Every image is independent: one failing doesn't stop the rest or the
|
||||
* import as a whole. */
|
||||
export async function attachGalleryImagesAsSupportingFiles(
|
||||
printId: string | undefined,
|
||||
images: { url: string; filename: string }[],
|
||||
skipUrl: string | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!printId || !images.length) return;
|
||||
const toSave = images.filter((image) => image.url !== skipUrl).slice(0, GALLERY_IMAGE_MAX_COUNT);
|
||||
|
||||
for (const image of toSave) {
|
||||
let tempPath: string | null = null;
|
||||
try {
|
||||
const res = await rawFetch(image.url, { "User-Agent": IMPORT_USER_AGENT, Accept: "image/*" });
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
const contentLength = res.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > GALLERY_IMAGE_MAX_BYTES) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (buf.length > GALLERY_IMAGE_MAX_BYTES) continue;
|
||||
|
||||
tempPath = path.join(os.tmpdir(), `printstash-gallery-${crypto.randomBytes(8).toString("hex")}`);
|
||||
await fs.writeFile(tempPath, buf);
|
||||
await saveFileFromTemp(printId, tempPath, image.filename, res.headers.get("content-type"));
|
||||
tempPath = null;
|
||||
} catch {
|
||||
// best-effort only
|
||||
} finally {
|
||||
if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads a URL and creates a single-plate Print from it (POST /import). */
|
||||
export async function importPrintFromUrl(
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ print: Print; plates: Plate[] }> {
|
||||
): Promise<{ print: Print; plates: Plate[]; author: Author | null }> {
|
||||
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
const printMeta: PrintMetaInput = {
|
||||
title: body.title ?? meta.title ?? null,
|
||||
notes: body.notes ?? meta.description ?? null,
|
||||
tags: body.tags && body.tags.length ? body.tags : meta.tags,
|
||||
folderId: body.folder_id ?? null,
|
||||
creator: meta.creator ?? null,
|
||||
authorId: author?.id ?? null,
|
||||
};
|
||||
try {
|
||||
const result = await createPrint(printMeta, path.parse(filename).name, [{ filename, mime, tempFilePath: tempPath }]);
|
||||
await applyCoverThumbnailIfMissing(result.plates[0]?.id, meta.previewImageUrl);
|
||||
return result;
|
||||
await attachGalleryImagesAsSupportingFiles(result.print.id, meta.galleryImages, meta.previewImageUrl);
|
||||
return { ...result, author };
|
||||
} finally {
|
||||
if (fsSync.existsSync(tempPath)) await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import { decodeHtmlEntities, htmlToPlainText, type ImportedPageMetadata } from "./importResolvers";
|
||||
import { fetchViaFlaresolverr, isFlaresolverrEnabled, looksLikeCloudflareBlock } from "./flaresolverr";
|
||||
import { decodeHtmlEntities, htmlToPlainText, type ImportedAuthorInfo, type ImportedPageMetadata } from "./importResolvers";
|
||||
|
||||
// MakerWorld's own website (makerworld.com) sits behind Cloudflare bot management and a
|
||||
// separate Geetest CAPTCHA on its download-resolution endpoints. api.bambulab.com is the
|
||||
@@ -9,7 +10,12 @@ import { decodeHtmlEntities, htmlToPlainText, type ImportedPageMetadata } from "
|
||||
// projects (kloshi-io/makerworld-api-reverse, maziggy/bambuddy, Pr0zak/YASTL).
|
||||
const DESIGN_API_BASE = "https://api.bambulab.com/v1/design-service";
|
||||
const PROFILE_DOWNLOAD_BASE = "https://api.bambulab.com/v1/iot-service/api/user/profile";
|
||||
// This one lives on makerworld.com rather than api.bambulab.com (unlike the two above) -- but
|
||||
// like the other makerworld.com /api/v1/* paths already used elsewhere (favorites/collections),
|
||||
// it's a clean, unauthenticated, non-Cloudflare-gated JSON endpoint. Confirmed live.
|
||||
const AUTHOR_PROFILE_BASE = "https://makerworld.com/api/v1/design-user-service/user/profile";
|
||||
const CLOUD_API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
|
||||
const MAKERWORLD_PROVIDER = "makerworld";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -141,6 +147,91 @@ function pickString(source: Record<string, unknown>, keys: string[]): string | n
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Same defensive fallback used for the other makerworld.com /api/v1/* endpoints (collections):
|
||||
* not seen behind Cloudflare's challenge in practice, but retry once through FlareSolverr
|
||||
* rather than failing outright if that ever changes. No bearer token needed -- author profiles
|
||||
* are public. */
|
||||
async function fetchAuthorProfileJson(uid: string): Promise<unknown | null> {
|
||||
const url = `${AUTHOR_PROFILE_BASE}/${uid}`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "application/json",
|
||||
Referer: "https://makerworld.com/",
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { headers, redirect: "follow", signal: controller.signal });
|
||||
if (res.status === 403 && isFlaresolverrEnabled() && looksLikeCloudflareBlock(res.headers)) {
|
||||
const solved = await fetchViaFlaresolverr(url, null);
|
||||
if (!solved) return null;
|
||||
try {
|
||||
return JSON.parse(solved.body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the richer author record (bio, links, background image) for a MakerWorld uid, for
|
||||
* the Author table. Best-effort: a failure here shouldn't fail the import, since `creator`
|
||||
* (plain string, from the design's own embedded designCreator summary) already covers the
|
||||
* simple display case. */
|
||||
async function fetchMakerworldAuthorInfo(uid: string): Promise<ImportedAuthorInfo | null> {
|
||||
const data = await fetchAuthorProfileJson(uid);
|
||||
if (!isRecord(data)) return null;
|
||||
const personal = isRecord(data.personal) ? data.personal : {};
|
||||
const links = Array.isArray(personal.links)
|
||||
? personal.links.filter((link): link is string => typeof link === "string" && link.trim().length > 0)
|
||||
: [];
|
||||
return {
|
||||
provider: MAKERWORLD_PROVIDER,
|
||||
externalId: uid,
|
||||
name: pickString(data, ["name"]),
|
||||
handle: pickString(personal, ["handle"]) ?? pickString(data, ["handle"]),
|
||||
bio: pickString(personal, ["bio"]),
|
||||
bioTranslated: pickString(personal, ["bioTranslated"]),
|
||||
links,
|
||||
avatarUrl: pickString(data, ["avatar"]),
|
||||
backgroundUrl: pickString(personal, ["backgroundUrl"]),
|
||||
};
|
||||
}
|
||||
|
||||
export type MakerworldGalleryImage = { url: string; filename: string };
|
||||
|
||||
/** The model page's photo gallery -- design.designExtension.design_pictures -- distinct from
|
||||
* coverUrl/coverPortrait/coverLandscape, which are just crops of the same single cover image
|
||||
* for different UI contexts. Confirmed live: a model can have several of these (renders and/or
|
||||
* isRealLifePhoto: 1 real-world photos of the print). */
|
||||
function extractGalleryImages(design: Record<string, unknown>): MakerworldGalleryImage[] {
|
||||
const extension = design.designExtension;
|
||||
if (!isRecord(extension)) return [];
|
||||
const pictures = extension.design_pictures;
|
||||
if (!Array.isArray(pictures)) return [];
|
||||
const images: MakerworldGalleryImage[] = [];
|
||||
for (const picture of pictures) {
|
||||
if (!isRecord(picture)) continue;
|
||||
const url = typeof picture.url === "string" ? picture.url.trim() : "";
|
||||
if (!url) continue;
|
||||
const filename = typeof picture.name === "string" && picture.name.trim() ? picture.name.trim() : null;
|
||||
images.push({ url, filename: filename ?? url.split("/").pop() ?? "preview.jpg" });
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a MakerWorld design to a real, directly-downloadable (signed S3) URL entirely
|
||||
* through api.bambulab.com -- no Cloudflare, no cookie-gated web session, no HTML scraping.
|
||||
@@ -204,9 +295,13 @@ export async function resolveMakerworldViaCloudApi(
|
||||
? design.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0).map((tag) => tag.trim())
|
||||
: [];
|
||||
const summary = typeof design.summary === "string" ? design.summary : null;
|
||||
const creator = isRecord(design.designCreator) ? pickString(design.designCreator, ["nickName", "name", "handle"]) : null;
|
||||
const designCreator = isRecord(design.designCreator) ? design.designCreator : null;
|
||||
const creator = designCreator ? pickString(designCreator, ["nickName", "name", "handle"]) : null;
|
||||
const creatorUid = designCreator?.uid != null ? String(designCreator.uid) : null;
|
||||
const previewImageUrl = pickString(design, ["coverUrl", "coverPortrait", "coverLandscape"]);
|
||||
const title = pickString(design, ["title"]);
|
||||
const galleryImages = extractGalleryImages(design);
|
||||
const author = creatorUid ? await fetchMakerworldAuthorInfo(creatorUid) : null;
|
||||
|
||||
return {
|
||||
downloadUrl: body.url,
|
||||
@@ -214,9 +309,11 @@ export async function resolveMakerworldViaCloudApi(
|
||||
title: title ? decodeHtmlEntities(title) : null,
|
||||
tags,
|
||||
description: summary ? htmlToPlainText(summary) : null,
|
||||
creator,
|
||||
creator: author?.name ?? creator,
|
||||
previewImageUrl,
|
||||
filename: pickString(body, ["filename"]),
|
||||
galleryImages,
|
||||
author,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export type PrintMetaInput = {
|
||||
folderId?: string | null;
|
||||
creator?: string | null;
|
||||
collection?: string | null;
|
||||
/** Id of an already-upserted Author row (see authorService.ts), e.g. "makerworld:12345". */
|
||||
authorId?: string | null;
|
||||
};
|
||||
|
||||
async function placeFile(input: NewPlateInput, destAbsPath: string): Promise<string | null> {
|
||||
@@ -159,6 +161,7 @@ export async function createPrint(
|
||||
collection: meta.collection?.trim() || null,
|
||||
tags: (meta.tags || []).map((t) => t.trim()).filter(Boolean),
|
||||
folderId: meta.folderId ?? null,
|
||||
authorId: meta.authorId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { toPrintOut, type PrintOut } from "../dto";
|
||||
import type { Plate, Print, PrintFile } from "@prisma/client";
|
||||
import type { Author, Plate, Print, PrintFile } from "@prisma/client";
|
||||
|
||||
export type FullPrint = { print: Print; plates: Plate[]; files: PrintFile[]; preparedFile: PrintFile | null };
|
||||
export type FullPrint = {
|
||||
print: Print & { author: Author | null };
|
||||
plates: Plate[];
|
||||
files: PrintFile[];
|
||||
preparedFile: PrintFile | null;
|
||||
};
|
||||
|
||||
export async function loadFullPrint(printId: string): Promise<FullPrint> {
|
||||
const print = await prisma.print.findUnique({ where: { id: printId } });
|
||||
const print = await prisma.print.findUnique({ where: { id: printId }, include: { author: true } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const [plates, files] = await Promise.all([
|
||||
prisma.plate.findMany({ where: { printId }, orderBy: { position: "asc" } }),
|
||||
@@ -20,5 +25,5 @@ export async function loadFullPrint(printId: string): Promise<FullPrint> {
|
||||
|
||||
export async function printOutById(printId: string): Promise<PrintOut> {
|
||||
const full = await loadFullPrint(printId);
|
||||
return toPrintOut(full.print, full.plates, full.files, full.preparedFile);
|
||||
return toPrintOut(full.print, full.plates, full.files, full.preparedFile, full.print.author);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { HttpError, sanitizeFilename, guessMimeFromPath } from "../utils/fileUti
|
||||
import { IMPORT_MAX_BYTES } from "../config";
|
||||
import { listZipEntries as listRawZipEntries, readZipEntry } from "../utils/zipReader";
|
||||
import { validateParentFolder } from "./folderService";
|
||||
import { applyCoverThumbnailIfMissing } from "./importService";
|
||||
import { applyCoverThumbnailIfMissing, attachGalleryImagesAsSupportingFiles } from "./importService";
|
||||
import { createPrint, type PrintMetaInput } from "./printCreation";
|
||||
import type { Print, Plate } from "@prisma/client";
|
||||
|
||||
@@ -91,7 +91,9 @@ export type ZipExtractOptions = {
|
||||
tags?: string[];
|
||||
folderId?: string | null;
|
||||
creator?: string | null;
|
||||
authorId?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
galleryImages?: { url: string; filename: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -159,6 +161,7 @@ export async function extractZipEntriesToPrints(
|
||||
tags: options.tags ?? [],
|
||||
folderId: targetFolderId,
|
||||
creator: options.creator ?? null,
|
||||
authorId: options.authorId ?? null,
|
||||
};
|
||||
const mime = guessMimeFromPath(filename);
|
||||
const { print, plates } = await createPrint(meta, path.parse(filename).name, [
|
||||
@@ -166,6 +169,7 @@ export async function extractZipEntriesToPrints(
|
||||
]);
|
||||
tempPath = null;
|
||||
await applyCoverThumbnailIfMissing(plates[0]?.id, options.previewImageUrl);
|
||||
await attachGalleryImagesAsSupportingFiles(print.id, options.galleryImages ?? [], options.previewImageUrl);
|
||||
prints.push({ ...print, plates });
|
||||
} catch {
|
||||
if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
|
||||
@@ -30,12 +30,26 @@ export type PrintFile = {
|
||||
url: string; // /print/{printId}/files/{fileId}
|
||||
};
|
||||
|
||||
export type Author = {
|
||||
id: string;
|
||||
provider: string;
|
||||
external_id: string;
|
||||
name: string | null;
|
||||
handle: string | null;
|
||||
bio: string | null;
|
||||
bio_translated: string | null;
|
||||
links: string[];
|
||||
avatar_url: string | null;
|
||||
background_url: string | null;
|
||||
};
|
||||
|
||||
export type Print = {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
notes?: string | null;
|
||||
creator?: string | null;
|
||||
author?: Author | null;
|
||||
collection?: string | null;
|
||||
tags: string[];
|
||||
folder_id?: string | null;
|
||||
|
||||
Reference in new issue
Block a user