Browse Source

Browse a Manyfold library and import its files (issue #1471)

The MakerWorld page becomes Model Sources, with a MakerWorld tab and a
Manyfold tab. Bambuddy signs in to a self-hosted Manyfold with an OAuth
application (public read scopes), lists and searches its models with their
previews, and imports 3MF, STL and STEP files into the library, from where
they are sliced and printed like any other file.

New permissions manyfold:view and manyfold:import are granted once to the
groups that hold the matching MakerWorld permissions. The client secret is
never returned and is left out of GitHub backups. The Manyfold URL and every
redirect pass the LAN-service URL check. Old /makerworld links open the
MakerWorld tab.

Several files import at once: tick files in a model, or Select all, then
Import selected or Import all; or tick models in the grid and import all
their printable files. Files import one after another, failures don't stop
the rest, and a summary reports the outcome.
maziggy 2 days ago
parent
commit
3480d39434
56 changed files with 4993 additions and 37 deletions
  1. 8 0
      CHANGELOG.md
  2. 16 5
      backend/app/api/routes/library.py
  3. 8 1
      backend/app/api/routes/makerworld.py
  4. 377 0
      backend/app/api/routes/manyfold.py
  5. 1 0
      backend/app/api/routes/support.py
  6. 2 0
      backend/app/core/auth.py
  7. 27 0
      backend/app/core/database.py
  8. 13 0
      backend/app/core/permissions.py
  9. 2 0
      backend/app/main.py
  10. 93 0
      backend/app/schemas/manyfold.py
  11. 1 1
      backend/app/services/github_backup.py
  12. 6 2
      backend/app/services/model_providers/__init__.py
  13. 5 0
      backend/app/services/model_providers/manyfold/__init__.py
  14. 70 0
      backend/app/services/model_providers/manyfold/config.py
  15. 79 0
      backend/app/services/model_providers/manyfold/provider.py
  16. 522 0
      backend/app/services/model_providers/manyfold/service.py
  17. 186 0
      backend/tests/_fixtures/manyfold.py
  18. 378 0
      backend/tests/integration/test_manyfold_api.py
  19. 418 0
      backend/tests/unit/services/test_manyfold_service.py
  20. 4 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  21. 7 0
      backend/tests/unit/test_support_helpers.py
  22. 2 1
      frontend/scripts/check-i18n-parity.mjs
  23. 8 4
      frontend/src/App.tsx
  24. 15 4
      frontend/src/__tests__/components/Layout.test.tsx
  25. 9 0
      frontend/src/__tests__/components/LibraryFileDetailsModal.test.tsx
  26. 367 0
      frontend/src/__tests__/components/ManyfoldTab.test.tsx
  27. 134 0
      frontend/src/__tests__/pages/ModelSourcesPage.test.tsx
  28. 94 0
      frontend/src/api/client.ts
  29. 3 2
      frontend/src/components/Layout.tsx
  30. 6 1
      frontend/src/components/LibraryFileDetailsModal.tsx
  31. 924 0
      frontend/src/components/ManyfoldTab.tsx
  32. 74 0
      frontend/src/i18n/locales/de.ts
  33. 74 0
      frontend/src/i18n/locales/en.ts
  34. 74 0
      frontend/src/i18n/locales/es.ts
  35. 74 0
      frontend/src/i18n/locales/fr.ts
  36. 74 0
      frontend/src/i18n/locales/it.ts
  37. 74 0
      frontend/src/i18n/locales/ja.ts
  38. 74 0
      frontend/src/i18n/locales/ko.ts
  39. 74 0
      frontend/src/i18n/locales/nl.ts
  40. 74 0
      frontend/src/i18n/locales/pt-BR.ts
  41. 74 0
      frontend/src/i18n/locales/ru.ts
  42. 74 0
      frontend/src/i18n/locales/sv.ts
  43. 74 0
      frontend/src/i18n/locales/tr.ts
  44. 74 0
      frontend/src/i18n/locales/uk.ts
  45. 74 0
      frontend/src/i18n/locales/zh-CN.ts
  46. 74 0
      frontend/src/i18n/locales/zh-TW.ts
  47. 17 11
      frontend/src/pages/MakerworldPage.tsx
  48. 78 0
      frontend/src/pages/ModelSourcesPage.tsx
  49. 0 0
      static/assets/ImagePreviewModal-DFKL6Wuy.js
  50. 0 1
      static/assets/PdfPreviewModal-DWMSVJDW.js
  51. 0 0
      static/assets/SpreadsheetPreviewModal-6L0JEhlx.js
  52. 1 0
      static/assets/index-C5OR618T.css
  53. 0 1
      static/assets/index-D1FhwA-P.css
  54. 0 1
      static/assets/index-D2LwYQoy.js
  55. 0 0
      static/assets/pdf-CzFKNr8-.js
  56. 2 2
      static/index.html

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,14 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **Browse your Manyfold library in Bambuddy and import its files (#1471, requested by @hibikipr)** — [Manyfold](https://manyfold.app) is a self-hosted library for 3D models. The sidebar entry **MakerWorld** is now **Model Sources**, with a **MakerWorld** tab, which works as before, and a **Manyfold** tab.
+  - **Connecting:** in Manyfold, open Settings → API and create an application with the scopes `public` and `read`. Then enter Manyfold's URL and the application's client ID and secret in the Manyfold tab. **Test connection** shows how many models Bambuddy can see. Bambuddy sees the models the application's owner can see in Manyfold, and never writes to Manyfold.
+  - **Browsing:** search and page through the models with their previews, and open one to see its description, tags, licence and files.
+  - **Importing:** **Import** downloads a 3MF, STL or STEP file into the library, into a **Manyfold** folder or one you pick. From there you slice and print it like any other file. An imported file shows **In library**, with **Show in library** and **Slice**, and importing it again doesn't download it twice. Nothing is copied until you import it.
+  - **Several at once:** in a model, tick files or **Select all**, then **Import selected**, or use **Import all**. In the model grid, tick models (or **Select page**); the ticks stay while you page and search, and **Import their files** imports every printable file of those models that isn't in the library yet. Files import one after another, a failure doesn't stop the rest, and a summary says how many were imported, already there or failed.
+  - **Permissions:** two new permissions, `manyfold:view` and `manyfold:import`. Existing groups get them once, matching what they have for MakerWorld. Connecting needs `settings:update`. Until Manyfold is connected, only users who can connect it see the tab.
+  - **The secret:** the client secret is never sent back to the browser and is left out of GitHub backups.
+  - **Old links:** links and bookmarks to `/makerworld` open the MakerWorld tab, and a sidebar you reordered or hid entries in keeps its layout.
 - **The chamber light can turn on whenever the camera is used (#1655, requested by @dadino)** — A printer kept dark between uses showed a black live view and sent black photos with its notifications. Settings → Camera has a new **Turn on chamber light for camera** choice: **Off** (the default), **All printers**, or **Selected printers**, picked from a list you can search by name or location.
 - **The chamber light can turn on whenever the camera is used (#1655, requested by @dadino)** — A printer kept dark between uses showed a black live view and sent black photos with its notifications. Settings → Camera has a new **Turn on chamber light for camera** choice: **Off** (the default), **All printers**, or **Selected printers**, picked from a list you can search by name or location.
   - **When it turns on:** while anyone watches the live view, including the camera wall and a streaming overlay, and for automatic pictures: notification photos, the finish photo and the snapshot that Home Assistant and other automations fetch.
   - **When it turns on:** while anyone watches the live view, including the camera wall and a streaming overlay, and for automatic pictures: notification photos, the finish photo and the snapshot that Home Assistant and other automations fetch.
   - **When it turns off:** 15 seconds after the last use, so a reconnecting viewer or the camera wall's refresh doesn't make it flash. Bambuddy only turns off a light it turned on: one that was already on stays on, and switching the light by hand on the printer card hands it back to you.
   - **When it turns off:** 15 seconds after the last use, so a reconnecting viewer or the camera wall's refresh doesn't make it flash. Bambuddy only turns off a light it turned on: one that was already on stays on, and switching the light by hand on the printer card hands it back to you.

+ 16 - 5
backend/app/api/routes/library.py

@@ -613,17 +613,17 @@ async def save_3mf_bytes_to_library(
     source_url: str | None = None,
     source_url: str | None = None,
     owner_id: int | None = None,
     owner_id: int | None = None,
 ) -> tuple[LibraryFile, bool]:
 ) -> tuple[LibraryFile, bool]:
-    """Save a 3MF blob into the library and return ``(library_file, was_existing)``.
+    """Save a fetched file into the library and return ``(library_file, was_existing)``.
 
 
-    Used by routes that receive a 3MF in-process rather than as a multipart
-    upload (currently: MakerWorld import; reusable for any future source that
-    fetches bytes server-side). Deduplicates by ``source_url`` when provided —
+    Used by routes that receive a file in-process rather than as a multipart
+    upload: the MakerWorld import (3MF) and the Manyfold import (3MF, STL,
+    STEP). Deduplicates by ``source_url`` when provided —
     if a LibraryFile with the same source_url already exists, the existing
     if a LibraryFile with the same source_url already exists, the existing
     row is returned and the bytes are NOT re-saved (MakerWorld signed URLs
     row is returned and the bytes are NOT re-saved (MakerWorld signed URLs
     change each download, so hash-based dedupe alone would miss re-imports).
     change each download, so hash-based dedupe alone would miss re-imports).
 
 
     Parses 3MF metadata + thumbnail the same way the multipart upload route
     Parses 3MF metadata + thumbnail the same way the multipart upload route
-    does, via :class:`ThreeMFParser`. Paths are stored as relative so the
+    does, via :class:`ThreeMFParser`, and renders an STL's thumbnail. Paths are stored as relative so the
     library is portable across installs.
     library is portable across installs.
     """
     """
     # Source-URL-based dedupe: return the existing row untouched.
     # Source-URL-based dedupe: return the existing row untouched.
@@ -677,6 +677,17 @@ async def save_3mf_bytes_to_library(
             # still land in the library so the user can see / delete it rather
             # still land in the library so the user can see / delete it rather
             # than failing the whole request.
             # than failing the whole request.
             logger.warning("Failed to parse 3MF %s: %s", filename, exc)
             logger.warning("Failed to parse 3MF %s: %s", filename, exc)
+    elif ext == ".stl":
+        # Manyfold imports (#1471) bring STLs. Same thumbnail as the multipart
+        # upload gives them, with the same pre-skip for stubs too small to hold
+        # a triangle.
+        try:
+            if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
+                thumbnail_path = await asyncio.to_thread(
+                    generate_stl_thumbnail, file_path, get_library_thumbnails_dir()
+                )
+        except Exception as exc:  # noqa: BLE001 — a thumbnail must never fail the import
+            logger.warning("Failed to render STL thumbnail for %s: %s", filename, exc)
 
 
     library_file = LibraryFile(
     library_file = LibraryFile(
         folder_id=folder_id,
         folder_id=folder_id,

+ 8 - 1
backend/app/api/routes/makerworld.py

@@ -91,10 +91,17 @@ def _provider_for_source(source_type: str) -> ModelProvider:
     is the *repr* of its argument and would ship the quotes to the client.
     is the *repr* of its argument and would ship the quotes to the client.
     """
     """
     try:
     try:
-        return registry.get(source_type)
+        provider = registry.get(source_type)
     except KeyError as exc:
     except KeyError as exc:
         msg = f"No model provider registered for source_type {source_type!r}"
         msg = f"No model provider registered for source_type {source_type!r}"
         raise HTTPException(status_code=400, detail=msg) from exc
         raise HTTPException(status_code=400, detail=msg) from exc
+    # A provider that claims no pasted URLs (Manyfold, #1471) is browsed
+    # through routes of its own, which apply its own checks; it must not be
+    # importable through this pasted-URL flow as well.
+    if not provider.host_patterns:
+        msg = f"Model provider {source_type!r} is not imported through this route"
+        raise HTTPException(status_code=400, detail=msg)
+    return provider
 
 
 
 
 async def _authorize_for_provider(
 async def _authorize_for_provider(

+ 377 - 0
backend/app/api/routes/manyfold.py

@@ -0,0 +1,377 @@
+"""Manyfold integration routes (#1471).
+
+Browse and search a self-hosted Manyfold library and import its files into
+the Bambuddy library, from where they are sliced and printed like any other
+file. Files are only fetched when someone imports one; nothing is mirrored.
+
+One connection serves the whole install: an admin stores the Manyfold URL and
+an OAuth application's client ID and secret (``/manyfold/config``), and the
+application's owner in Manyfold decides which models Bambuddy can see.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import Response
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
+from backend.app.api.routes.settings import set_setting
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.schemas.manyfold import (
+    ManyfoldConfigResponse,
+    ManyfoldConfigUpdate,
+    ManyfoldFile,
+    ManyfoldImportRequest,
+    ManyfoldImportResponse,
+    ManyfoldLibraryRef,
+    ManyfoldModel,
+    ManyfoldModelList,
+    ManyfoldStatus,
+    ManyfoldTestRequest,
+    ManyfoldTestResponse,
+)
+from backend.app.services.model_providers import manyfold_provider
+from backend.app.services.model_providers.base import ProviderResourceRef
+from backend.app.services.model_providers.manyfold.config import (
+    CLIENT_ID_KEY,
+    CLIENT_SECRET_KEY,
+    CONFIG_KEYS,
+    URL_KEY,
+    ManyfoldConfig,
+    load_config,
+    normalize_url,
+)
+from backend.app.services.model_providers.manyfold.service import (
+    ManyfoldError,
+    ManyfoldNotFoundError,
+    ManyfoldService,
+    clear_token_cache,
+    is_importable_filename,
+    valid_id,
+)
+from backend.app.utils.filename import INVALID_FILENAME_CHARS, InvalidFilenameError, validate_print_filename
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/manyfold", tags=["manyfold"])
+
+
+def _error(status_code: int, code: str, message: str) -> HTTPException:
+    # Structured detail: the frontend shows its own translated text for the code.
+    return HTTPException(status_code=status_code, detail={"code": code, "message": message})
+
+
+def _map_error(exc: ManyfoldError) -> HTTPException:
+    """Manyfold's refusals are not Bambuddy's: never answer them with 401 or 403."""
+    if isinstance(exc, ManyfoldNotFoundError):
+        status_code = 404
+    elif exc.code == "manyfold_not_configured":
+        status_code = 409
+    else:
+        status_code = 502
+    return _error(status_code, exc.code, str(exc))
+
+
+async def _service(db: AsyncSession) -> ManyfoldService:
+    config = await load_config(db)
+    if not config.configured:
+        raise _error(409, "manyfold_not_configured", "Manyfold is not set up")
+    return ManyfoldService(config)
+
+
+def _id(value: str) -> str:
+    try:
+        return valid_id(value)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+
+
+def _library_filename(name: str, file_id: str) -> str:
+    """The Manyfold file name, made safe for the printer's SD card."""
+    cleaned = "".join("_" if ch in INVALID_FILENAME_CHARS or ord(ch) < 0x20 else ch for ch in name)
+    cleaned = cleaned.rstrip(" .")
+    try:
+        validate_print_filename(cleaned)
+    except InvalidFilenameError:
+        cleaned = f"manyfold-{file_id}{os.path.splitext(name)[1].lower()}"
+    return cleaned
+
+
+# ---- connection ---------------------------------------------------------
+
+
+def _config_response(config: ManyfoldConfig) -> ManyfoldConfigResponse:
+    return ManyfoldConfigResponse(
+        url=config.url,
+        client_id=config.client_id,
+        has_client_secret=bool(config.client_secret),
+        configured=config.configured,
+    )
+
+
+@router.get("/config", response_model=ManyfoldConfigResponse)
+async def get_config(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """The stored connection, without the secret."""
+    return _config_response(await load_config(db))
+
+
+@router.put("/config", response_model=ManyfoldConfigResponse)
+async def update_config(
+    body: ManyfoldConfigUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Store the connection. An empty secret keeps the stored one."""
+    try:
+        url = normalize_url(body.url)
+    except ValueError as exc:
+        raise _error(400, "manyfold_bad_url", str(exc)) from exc
+    stored = await load_config(db)
+    secret = (body.client_secret or "").strip() or stored.client_secret
+    if not secret:
+        raise _error(400, "manyfold_secret_required", "Enter the client secret")
+    await set_setting(db, URL_KEY, url)
+    await set_setting(db, CLIENT_ID_KEY, body.client_id.strip())
+    await set_setting(db, CLIENT_SECRET_KEY, secret)
+    await db.commit()
+    clear_token_cache()
+    return _config_response(await load_config(db))
+
+
+@router.delete("/config", status_code=204)
+async def delete_config(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Disconnect Manyfold. Files imported earlier stay in the library."""
+    await db.execute(delete(Settings).where(Settings.key.in_(CONFIG_KEYS)))
+    await db.commit()
+    clear_token_cache()
+
+
+@router.post("/config/test", response_model=ManyfoldTestResponse)
+async def test_config(
+    body: ManyfoldTestRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Sign in with the entered values and count the models Bambuddy can see.
+
+    Nothing is stored. An empty secret tests the stored one, so an admin can
+    re-test without typing it again.
+    """
+    try:
+        url = normalize_url(body.url)
+    except ValueError as exc:
+        raise _error(400, "manyfold_bad_url", str(exc)) from exc
+    secret = (body.client_secret or "").strip() or (await load_config(db)).client_secret
+    if not secret:
+        raise _error(400, "manyfold_secret_required", "Enter the client secret")
+    service = ManyfoldService(ManyfoldConfig(url=url, client_id=body.client_id.strip(), client_secret=secret))
+    try:
+        return ManyfoldTestResponse(model_count=await service.check())
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+
+# ---- browsing -----------------------------------------------------------
+
+
+@router.get("/status", response_model=ManyfoldStatus)
+async def get_status(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+):
+    """Whether Manyfold is set up, and where it is."""
+    config = await load_config(db)
+    return ManyfoldStatus(configured=config.configured, url=config.url if config.configured else "")
+
+
+@router.get("/models", response_model=ManyfoldModelList)
+async def list_models(
+    q: str = "",
+    page: int = 1,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+):
+    """One page of Manyfold's models, optionally searched.
+
+    ``q`` takes Manyfold's own search syntax; the page size is the
+    application owner's Manyfold setting.
+    """
+    service = await _service(db)
+    try:
+        return ManyfoldModelList(**await service.list_models(query=q[:200], page=max(1, min(page, 100000))))
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+
+@router.get("/models/{model_id}", response_model=ManyfoldModel)
+async def get_model(
+    model_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+):
+    """A model's details and files, with the library file of each earlier import."""
+    model_id = _id(model_id)
+    service = await _service(db)
+    try:
+        model = await service.get_model(model_id)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+    imported: dict[str, ManyfoldLibraryRef] = {}
+    rows = await db.execute(
+        LibraryFile.active()
+        .where(manyfold_provider.source_url_filter(LibraryFile.source_url, model_id))
+        .order_by(LibraryFile.id)
+    )
+    for row in rows.scalars().all():
+        file_id = (row.source_url or "").rsplit("/", 1)[-1]
+        imported.setdefault(file_id, ManyfoldLibraryRef(id=row.id, filename=row.filename, folder_id=row.folder_id))
+
+    return ManyfoldModel(
+        id=model["id"],
+        name=model["name"],
+        caption=model["caption"],
+        description=model["description"],
+        license=model["license"],
+        tags=model["tags"],
+        url=model["url"],
+        has_preview=model["preview_file_id"] is not None,
+        files=[ManyfoldFile(**file, library_file=imported.get(file["id"])) for file in model["files"]],
+    )
+
+
+@router.get("/models/{model_id}/preview")
+async def get_preview(
+    model_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
+):
+    """The model's preview image, fetched from Manyfold.
+
+    The browser can't send Manyfold's token, and the page's CSP only allows
+    images from Bambuddy itself, so the image comes through here. The page
+    fetches it rather than pointing an ``<img>`` at it: a model without a
+    preview answers 404, and a failing ``<img>`` on a protected URL makes the
+    app renew its media token.
+    """
+    model_id = _id(model_id)
+    service = await _service(db)
+    try:
+        data, content_type = await service.fetch_preview(model_id)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+    return Response(content=data, media_type=content_type, headers={"Cache-Control": "private, max-age=3600"})
+
+
+# ---- import -------------------------------------------------------------
+
+
+async def _import_folder_id(db: AsyncSession, folder_id: int | None) -> int | None:
+    """The chosen folder, or the top-level "Manyfold" folder (created on first use)."""
+    if folder_id is not None:
+        folder = (await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one_or_none()
+        if folder is None:
+            raise HTTPException(status_code=404, detail="Folder not found")
+        if folder.is_external and folder.external_readonly:
+            raise HTTPException(status_code=403, detail="Cannot import into a read-only external folder")
+        return folder_id
+    name = manyfold_provider.default_folder_name
+    folder = (
+        await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == name,
+                LibraryFolder.parent_id.is_(None),
+                LibraryFolder.is_external.is_(False),
+            )
+        )
+    ).scalar_one_or_none()
+    if folder is None:
+        folder = LibraryFolder(name=name, parent_id=None)
+        db.add(folder)
+        await db.flush()
+    return folder.id
+
+
+@router.post("/import", response_model=ManyfoldImportResponse)
+async def import_file(
+    body: ManyfoldImportRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_IMPORT),
+):
+    """Download one Manyfold file into the library.
+
+    A file imported before, and still in the library, is returned as it is
+    rather than downloaded again.
+    """
+    model_id, file_id = _id(body.model_id), _id(body.file_id)
+    source_url = manyfold_provider.canonical_url(
+        ProviderResourceRef(source_type=manyfold_provider.source_type, external_id=model_id, sub_id=file_id)
+    )
+    existing = (
+        await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
+    ).scalar_one_or_none()
+    if existing is not None:
+        return ManyfoldImportResponse(
+            library_file_id=existing.id, filename=existing.filename, folder_id=existing.folder_id, was_existing=True
+        )
+
+    service = await _service(db)
+    try:
+        file = await service.get_file(model_id, file_id)
+        if not is_importable_filename(file["filename"]):
+            raise _error(
+                400,
+                "manyfold_not_importable",
+                "Only 3MF, STL and STEP files can be imported; Bambuddy can't slice or print the others",
+            )
+        data = await service.download_file(file)
+    except ManyfoldError as exc:
+        raise _map_error(exc) from exc
+    finally:
+        await service.close()
+
+    filename = _library_filename(file["filename"], file_id)
+    validate_print_file_upload(filename, data)
+    folder_id = await _import_folder_id(db, body.folder_id)
+    library_file, was_existing = await save_3mf_bytes_to_library(
+        db,
+        file_bytes=data,
+        filename=filename,
+        folder_id=folder_id,
+        source_type=manyfold_provider.source_type,
+        source_url=source_url,
+        owner_id=current_user.id if current_user else None,
+    )
+    logger.info(
+        "[MANYFOLD] Imported %s (model %s, file %s) as library file %s", filename, model_id, file_id, library_file.id
+    )
+    return ManyfoldImportResponse(
+        library_file_id=library_file.id,
+        filename=library_file.filename,
+        folder_id=library_file.folder_id,
+        was_existing=was_existing,
+    )

+ 1 - 0
backend/app/api/routes/support.py

@@ -1060,6 +1060,7 @@ async def _collect_support_info() -> dict:
             "host",
             "host",
             "broker",  # MQTT broker hostname / IP — network exposure
             "broker",  # MQTT broker hostname / IP — network exposure
             "credential",
             "credential",
+            "client_id",  # e.g. manyfold_client_id (#1471): half of an OAuth login
         }
         }
         # Value-based safety net: redact anything whose value carries an
         # Value-based safety net: redact anything whose value carries an
         # unambiguous secret prefix, even if the key name didn't match.
         # unambiguous secret prefix, even if the key name didn't match.

+ 2 - 0
backend/app/core/auth.py

@@ -122,6 +122,7 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     # working (they need the UI-language setting via API key).
     # working (they need the UI-language setting via API key).
     Permission.SETTINGS_READ: "can_read_status",
     Permission.SETTINGS_READ: "can_read_status",
     Permission.MAKERWORLD_VIEW: "can_read_status",
     Permission.MAKERWORLD_VIEW: "can_read_status",
+    Permission.MANYFOLD_VIEW: "can_read_status",
     # Pipeline definitions and run history are configuration + status: listing
     # Pipeline definitions and run history are configuration + status: listing
     # pipelines, reading a run, and the (write-free) POST check-eligibility
     # pipelines, reading a run, and the (write-free) POST check-eligibility
     # pre-flight. Authoring stays admin-only under PIPELINES_WRITE.
     # pre-flight. Authoring stays admin-only under PIPELINES_WRITE.
@@ -158,6 +159,7 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     Permission.LIBRARY_DELETE_OWN: "can_manage_library",
     Permission.LIBRARY_DELETE_OWN: "can_manage_library",
     Permission.LIBRARY_DELETE_ALL: "can_manage_library",
     Permission.LIBRARY_DELETE_ALL: "can_manage_library",
     Permission.MAKERWORLD_IMPORT: "can_manage_library",
     Permission.MAKERWORLD_IMPORT: "can_manage_library",
+    Permission.MANYFOLD_IMPORT: "can_manage_library",
     # can_manage_inventory — inventory write scope. Covers the documented
     # can_manage_inventory — inventory write scope. Covers the documented
     # spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
     # spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
     # (NFC scan, scale reading, system command/update) which used
     # (NFC scan, scale reading, system command/update) which used

+ 27 - 0
backend/app/core/database.py

@@ -5945,6 +5945,7 @@ async def seed_default_groups():
 
 
     from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
     from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
     from backend.app.models.group import Group
     from backend.app.models.group import Group
+    from backend.app.models.settings import Settings
     from backend.app.models.user import User
     from backend.app.models.user import User
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
@@ -6118,6 +6119,32 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
+        # Manyfold (#1471) is a second model source beside MakerWorld, so a
+        # group gets the same reach there it already has on MakerWorld. Runs
+        # once: an admin who later takes a Manyfold permission away keeps it
+        # taken away.
+        manyfold_flag = "_backfill_1471_manyfold_permissions_done"
+        flag_row = (await session.execute(select(Settings).where(Settings.key == manyfold_flag))).scalar_one_or_none()
+        if flag_row is None:
+            result = await session.execute(select(Group))
+            for group in result.scalars().all():
+                if not group.permissions:
+                    continue
+                perms = list(group.permissions)
+                added = [
+                    manyfold_perm
+                    for makerworld_perm, manyfold_perm in (
+                        ("makerworld:view", "manyfold:view"),
+                        ("makerworld:import", "manyfold:import"),
+                    )
+                    if makerworld_perm in perms and manyfold_perm not in perms
+                ]
+                if added:
+                    group.permissions = perms + added
+                    logger.info("Added %s to group '%s' (matches its MakerWorld access)", ", ".join(added), group.name)
+            session.add(Settings(key=manyfold_flag, value="true"))
+            await session.commit()
+
         # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
         # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
         # Administrators' contract is full access to every feature — fresh
         # Administrators' contract is full access to every feature — fresh
         # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
         # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]

+ 13 - 0
backend/app/core/permissions.py

@@ -166,6 +166,10 @@ class Permission(StrEnum):
     MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
     MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
     MAKERWORLD_IMPORT = "makerworld:import"  # Download 3MFs from MakerWorld into the library
     MAKERWORLD_IMPORT = "makerworld:import"  # Download 3MFs from MakerWorld into the library
 
 
+    # Manyfold Integration (#1471)
+    MANYFOLD_VIEW = "manyfold:view"  # Browse and search the connected Manyfold library
+    MANYFOLD_IMPORT = "manyfold:import"  # Download Manyfold files into the library
+
     # API Keys (admin-level)
     # API Keys (admin-level)
     API_KEYS_READ = "api_keys:read"
     API_KEYS_READ = "api_keys:read"
     API_KEYS_CREATE = "api_keys:create"
     API_KEYS_CREATE = "api_keys:create"
@@ -343,6 +347,10 @@ PERMISSION_CATEGORIES = {
         Permission.MAKERWORLD_VIEW,
         Permission.MAKERWORLD_VIEW,
         Permission.MAKERWORLD_IMPORT,
         Permission.MAKERWORLD_IMPORT,
     ],
     ],
+    "Manyfold": [
+        Permission.MANYFOLD_VIEW,
+        Permission.MANYFOLD_IMPORT,
+    ],
     "API Keys": [
     "API Keys": [
         Permission.API_KEYS_READ,
         Permission.API_KEYS_READ,
         Permission.API_KEYS_CREATE,
         Permission.API_KEYS_CREATE,
@@ -414,6 +422,9 @@ DEFAULT_GROUPS = {
             # MakerWorld integration
             # MakerWorld integration
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_IMPORT.value,
             Permission.MAKERWORLD_IMPORT.value,
+            # Manyfold integration
+            Permission.MANYFOLD_VIEW.value,
+            Permission.MANYFOLD_IMPORT.value,
             # Orca Cloud — needed for the Slice modal's Orca Cloud preset
             # Orca Cloud — needed for the Slice modal's Orca Cloud preset
             # picker to populate. Workshops that use Orca Cloud presets
             # picker to populate. Workshops that use Orca Cloud presets
             # need every operator to be able to authenticate. Bambu Cloud
             # need every operator to be able to authenticate. Bambu Cloud
@@ -522,6 +533,8 @@ DEFAULT_GROUPS = {
             Permission.WEBSOCKET_CONNECT.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_VIEW.value,
+            # Manyfold browsing only, for the same reason
+            Permission.MANYFOLD_VIEW.value,
         ],
         ],
         "is_system": True,
         "is_system": True,
     },
     },

+ 2 - 0
backend/app/main.py

@@ -50,6 +50,7 @@ from backend.app.api.routes import (
     location_ha_sensors,
     location_ha_sensors,
     maintenance,
     maintenance,
     makerworld,
     makerworld,
+    manyfold,
     metrics,
     metrics,
     mfa,
     mfa,
     notification_templates,
     notification_templates,
@@ -10730,6 +10731,7 @@ app.include_router(pipeline_runs.pipeline_run_router, prefix=app_settings.api_pr
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
+app.include_router(manyfold.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(connected_apps.router, prefix=app_settings.api_prefix)
 app.include_router(connected_apps.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)

+ 93 - 0
backend/app/schemas/manyfold.py

@@ -0,0 +1,93 @@
+"""Request and response shapes for the Manyfold routes (#1471)."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class ManyfoldConfigResponse(BaseModel):
+    url: str
+    client_id: str
+    # The secret itself is never sent back, only whether one is stored.
+    has_client_secret: bool
+    configured: bool
+
+
+class ManyfoldConfigUpdate(BaseModel):
+    url: str = Field(..., max_length=500)
+    client_id: str = Field(..., min_length=1, max_length=200)
+    # Left empty, the stored secret is kept.
+    client_secret: str | None = Field(default=None, max_length=500)
+
+
+class ManyfoldTestRequest(BaseModel):
+    url: str = Field(..., max_length=500)
+    client_id: str = Field(..., min_length=1, max_length=200)
+    # Left empty, the stored secret is tested.
+    client_secret: str | None = Field(default=None, max_length=500)
+
+
+class ManyfoldTestResponse(BaseModel):
+    model_count: int
+
+
+class ManyfoldStatus(BaseModel):
+    configured: bool
+    # For "Open in Manyfold" links; empty until configured.
+    url: str
+
+
+class ManyfoldModelSummary(BaseModel):
+    id: str
+    name: str
+
+
+class ManyfoldModelList(BaseModel):
+    total: int
+    page: int
+    has_next: bool
+    has_previous: bool
+    models: list[ManyfoldModelSummary]
+
+
+class ManyfoldLibraryRef(BaseModel):
+    """The library file an earlier import of a Manyfold file created."""
+
+    id: int
+    filename: str
+    folder_id: int | None
+
+
+class ManyfoldFile(BaseModel):
+    id: str
+    name: str
+    mime: str
+    importable: bool
+    # Set while the file imported earlier is still in the library.
+    library_file: ManyfoldLibraryRef | None = None
+
+
+class ManyfoldModel(BaseModel):
+    id: str
+    name: str
+    caption: str | None = None
+    description: str | None = None
+    license: str | None = None
+    tags: list[str]
+    url: str
+    has_preview: bool
+    files: list[ManyfoldFile]
+
+
+class ManyfoldImportRequest(BaseModel):
+    model_id: str = Field(..., min_length=1, max_length=64)
+    file_id: str = Field(..., min_length=1, max_length=64)
+    # None imports into the "Manyfold" folder, created on first use.
+    folder_id: int | None = None
+
+
+class ManyfoldImportResponse(BaseModel):
+    library_file_id: int
+    filename: str
+    folder_id: int | None
+    was_existing: bool

+ 1 - 1
backend/app/services/github_backup.py

@@ -743,7 +743,7 @@ class GitHubBackupService:
         settings = result.scalars().all()
         settings = result.scalars().all()
 
 
         # Filter out sensitive settings
         # Filter out sensitive settings
-        sensitive_keys = {"bambu_cloud_token", "auth_secret_key"}
+        sensitive_keys = {"bambu_cloud_token", "auth_secret_key", "manyfold_client_secret"}
         settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
         settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
 
 
         files["settings/app_settings.json"] = {
         files["settings/app_settings.json"] = {

+ 6 - 2
backend/app/services/model_providers/__init__.py

@@ -3,8 +3,9 @@
 The shared seam for "import a model from a 3D model website". Providers
 The shared seam for "import a model from a 3D model website". Providers
 implement the interface (a ``ModelProvider`` descriptor + a per-request
 implement the interface (a ``ModelProvider`` descriptor + a per-request
 ``ProviderService`` transport) and register an instance here; the registry
 ``ProviderService`` transport) and register an instance here; the registry
-routes pasted URLs to the owning provider via ``find_for_url``. MakerWorld is
-the first (and currently only) registered provider.
+routes pasted URLs to the owning provider via ``find_for_url``. MakerWorld
+claims pasted URLs; Manyfold (#1471) is self-hosted and browsed, so it claims
+none.
 """
 """
 
 
 from backend.app.services.model_providers.base import (
 from backend.app.services.model_providers.base import (
@@ -25,9 +26,11 @@ from backend.app.services.model_providers.base import (
     ProviderUrlError,
     ProviderUrlError,
 )
 )
 from backend.app.services.model_providers.makerworld import makerworld_provider
 from backend.app.services.model_providers.makerworld import makerworld_provider
+from backend.app.services.model_providers.manyfold import manyfold_provider
 from backend.app.services.model_providers.registry import ModelProviderRegistry, registry
 from backend.app.services.model_providers.registry import ModelProviderRegistry, registry
 
 
 registry.register(makerworld_provider)
 registry.register(makerworld_provider)
+registry.register(manyfold_provider)
 
 
 __all__ = [
 __all__ = [
     "ModelProvider",
     "ModelProvider",
@@ -47,5 +50,6 @@ __all__ = [
     "ProviderUnavailableError",
     "ProviderUnavailableError",
     "ProviderUrlError",
     "ProviderUrlError",
     "makerworld_provider",
     "makerworld_provider",
+    "manyfold_provider",
     "registry",
     "registry",
 ]
 ]

+ 5 - 0
backend/app/services/model_providers/manyfold/__init__.py

@@ -0,0 +1,5 @@
+"""Manyfold model provider package (#1471)."""
+
+from backend.app.services.model_providers.manyfold.provider import ManyfoldProvider, manyfold_provider
+
+__all__ = ["ManyfoldProvider", "manyfold_provider"]

+ 70 - 0
backend/app/services/model_providers/manyfold/config.py

@@ -0,0 +1,70 @@
+"""Where Bambuddy finds the Manyfold install, and how it signs in (#1471).
+
+Manyfold is self-hosted, so unlike MakerWorld there is no fixed host: an admin
+enters the install's URL and the client ID and secret of an OAuth application
+created in Manyfold (Settings -> API). The three values live in the settings
+table under their own keys. They are not part of ``AppSettings``, so the
+general settings API never returns them; the secret is never returned at all.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from urllib.parse import urlsplit
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+from backend.app.models.settings import Settings
+
+URL_KEY = "manyfold_url"
+CLIENT_ID_KEY = "manyfold_client_id"
+CLIENT_SECRET_KEY = "manyfold_client_secret"
+CONFIG_KEYS = (URL_KEY, CLIENT_ID_KEY, CLIENT_SECRET_KEY)
+
+
+@dataclass(frozen=True)
+class ManyfoldConfig:
+    url: str = ""
+    client_id: str = ""
+    client_secret: str = ""
+
+    @property
+    def configured(self) -> bool:
+        return bool(self.url and self.client_id and self.client_secret)
+
+
+def normalize_url(raw: str) -> str:
+    """The install's base URL without a trailing slash.
+
+    Accepts a sub-path (``https://example.com/manyfold``) for installs behind a
+    reverse proxy. Raises ``ValueError`` for anything that is not a plain
+    http(s) URL with a host, and, like Spoolman's, for what the LAN-service
+    tier refuses (cloud-metadata endpoints, numeric-encoded IPs, ...).
+    Loopback and private addresses stay allowed: Manyfold usually runs on
+    the same host or LAN.
+    """
+    candidate = (raw or "").strip()
+    try:
+        parts = urlsplit(candidate)
+    except ValueError as exc:
+        raise ValueError("The Manyfold URL is not a valid URL") from exc
+    if parts.scheme not in ("http", "https") or not parts.hostname:
+        raise ValueError("The Manyfold URL must start with http:// or https:// and name a host")
+    if parts.username or parts.password:
+        raise ValueError("The Manyfold URL must not contain a user name or password")
+    if parts.query or parts.fragment:
+        raise ValueError("The Manyfold URL must not contain a query or fragment")
+    assert_safe_lan_service_url(candidate, label="Manyfold URL")
+    return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}"
+
+
+async def load_config(db: AsyncSession) -> ManyfoldConfig:
+    rows = await db.execute(select(Settings.key, Settings.value).where(Settings.key.in_(CONFIG_KEYS)))
+    values = {key: value or "" for key, value in rows.all()}
+    return ManyfoldConfig(
+        url=values.get(URL_KEY, ""),
+        client_id=values.get(CLIENT_ID_KEY, ""),
+        client_secret=values.get(CLIENT_SECRET_KEY, ""),
+    )

+ 79 - 0
backend/app/services/model_providers/manyfold/provider.py

@@ -0,0 +1,79 @@
+"""Manyfold model provider (#1471).
+
+Manyfold is a self-hosted library, so the provider has no fixed host and
+claims no pasted URLs: models are browsed and searched on the Manyfold tab of
+the Model Sources page, not pasted. The descriptor still carries what every
+provider declares (identity, permissions, import folder) and builds the
+per-request service from the stored connection.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import httpx
+
+from backend.app.core.permissions import Permission
+from backend.app.services.model_providers.base import (
+    ModelProvider,
+    ProviderAuthConfig,
+    ProviderAuthType,
+    ProviderResourceRef,
+    ProviderService,
+)
+from backend.app.services.model_providers.manyfold.config import load_config
+from backend.app.services.model_providers.manyfold.service import ManyfoldService, ManyfoldUnavailableError
+
+if TYPE_CHECKING:
+    from sqlalchemy.ext.asyncio import AsyncSession
+
+    from backend.app.models.user import User
+
+
+class ManyfoldProvider(ModelProvider):
+    source_type = "manyfold"
+    display_name = "Manyfold"
+    host_patterns = ()
+    auth = ProviderAuthConfig(
+        auth_type=ProviderAuthType.ACCESS_TOKEN,
+        display_label="Manyfold OAuth application",
+        description="Bambuddy signs in with the client ID and secret of an OAuth application created in Manyfold.",
+        credential_fields=("url", "client_id", "client_secret"),
+        setup_hint="In Manyfold, open Settings -> API and create an application with the 'public read' scopes.",
+    )
+    default_folder_name = "Manyfold"
+    view_permission = Permission.MANYFOLD_VIEW
+    import_permission = Permission.MANYFOLD_IMPORT
+
+    async def build_service(
+        self,
+        *,
+        db: AsyncSession,
+        user: User | None,
+        api_key_owner: User | None = None,
+        client: httpx.AsyncClient | None = None,
+    ) -> ProviderService:
+        # One connection for the whole install: the OAuth application's owner
+        # in Manyfold decides what Bambuddy can see, not the Bambuddy user.
+        return ManyfoldService(await load_config(db), client=client)
+
+    def parse_url(self, url: str) -> ProviderResourceRef:
+        raise ManyfoldUnavailableError("Manyfold models are browsed, not pasted")
+
+    def canonical_url(self, ref: ProviderResourceRef) -> str:
+        """``manyfold:<model>/<file>`` names one file of one model.
+
+        Deliberately not the install's URL: moving Manyfold to another address
+        must not make every earlier import look new.
+        """
+        if ref.sub_id:
+            return f"manyfold:{ref.external_id}/{ref.sub_id}"
+        return f"manyfold:{ref.external_id}"
+
+    def source_url_filter(self, column, external_id: str):
+        # Ids may contain "_", which LIKE would read as "any character".
+        escaped = external_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+        return column.like(f"manyfold:{escaped}/%", escape="\\")
+
+
+manyfold_provider = ManyfoldProvider()

+ 522 - 0
backend/app/services/model_providers/manyfold/service.py

@@ -0,0 +1,522 @@
+"""Manyfold API client (#1471).
+
+Talks to a self-hosted Manyfold install through its v0 API: list and search
+models, read a model's files, download one file and fetch a model's preview
+image. Bambuddy signs in with an OAuth application's client ID and secret
+(the client-credentials flow) and asks only for the ``public read`` scopes,
+which cover everything here; it never writes to Manyfold.
+
+Manyfold limits the token endpoint to 10 requests in 3 minutes, so the token
+is cached until shortly before it expires (two hours by default) and shared
+by every request.
+
+Request URLs are always built from the configured base URL and validated ids.
+The ``@id`` and ``contentUrl`` links in Manyfold's responses are read for the
+ids and the file name they carry, never fetched as given.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import logging
+import os
+import re
+import time
+from typing import Any
+from urllib.parse import unquote, urlsplit
+
+import httpx
+
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+from backend.app.services.model_providers.base import (
+    ProviderAuthError,
+    ProviderDownload,
+    ProviderDownloadInfo,
+    ProviderError,
+    ProviderForbiddenError,
+    ProviderNotFoundError,
+    ProviderResolvedModel,
+    ProviderResourceRef,
+    ProviderService,
+    ProviderStatus,
+    ProviderUnavailableError,
+)
+from backend.app.services.model_providers.manyfold.config import ManyfoldConfig
+
+logger = logging.getLogger(__name__)
+
+API_MEDIA_TYPE = "application/vnd.manyfold.v0+json"
+SCOPES = "public read"
+
+# Manyfold ids are short public ids such as "x7q3k2pa".
+_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
+_MODEL_ID_IN_URL = re.compile(r"/models/([A-Za-z0-9_-]{1,64})(?:/|$)")
+_FILE_ID_IN_URL = re.compile(r"/model_files/([A-Za-z0-9_-]{1,64})(?:[/.?]|$)")
+
+# What Bambuddy can slice or print, by Manyfold's MIME type and by extension.
+IMPORTABLE_MIME_TYPES = frozenset({"model/3mf", "model/stl", "model/step"})
+IMPORTABLE_EXTENSIONS = (".3mf", ".stl", ".step", ".stp")
+
+MAX_FILE_BYTES = 200 * 1024 * 1024
+_MAX_REDIRECTS = 5
+MAX_PREVIEW_BYTES = 10 * 1024 * 1024
+
+# Renew this long before Manyfold's own expiry, so a request never starts
+# with a token that dies on the way.
+_TOKEN_MARGIN_SECONDS = 120
+
+_IMAGE_SIGNATURES = (
+    (b"\x89PNG\r\n\x1a\n", "image/png"),
+    (b"\xff\xd8\xff", "image/jpeg"),
+    (b"GIF87a", "image/gif"),
+    (b"GIF89a", "image/gif"),
+)
+
+# (base url, client id, secret digest) -> (token, monotonic expiry)
+_token_cache: dict[tuple[str, str, str], tuple[str, float]] = {}
+# Credentials Manyfold just refused, and until when that answer is reused.
+# Without it, every request of a page or a bulk import would try to sign in
+# again with the same wrong secret and use up Manyfold's 10 sign-ins in 3
+# minutes, so the corrected secret would then be refused as well.
+_refused: dict[tuple[str, str, str], tuple[ProviderError, float]] = {}
+_REFUSED_FOR_SECONDS = 30.0
+# One sign-in at a time, so a page of previews opening at once asks for one
+# token rather than a dozen. Bound to the running loop, recreated if it changes.
+_token_lock: asyncio.Lock | None = None
+_token_lock_loop: asyncio.AbstractEventLoop | None = None
+
+
+def _sign_in_lock() -> asyncio.Lock:
+    global _token_lock, _token_lock_loop
+    loop = asyncio.get_running_loop()
+    if _token_lock is None or _token_lock_loop is not loop:
+        _token_lock = asyncio.Lock()
+        _token_lock_loop = loop
+    return _token_lock
+
+
+class ManyfoldError(ProviderError):
+    """Base exception for Manyfold API errors.
+
+    ``code`` names the failure for the frontend, which shows its own
+    translated text for it; the message is the English fallback.
+    """
+
+    default_code = "manyfold_failed"
+
+    def __init__(self, message: str, code: str | None = None):
+        super().__init__(message)
+        self.code = code or self.default_code
+
+
+class ManyfoldAuthError(ProviderAuthError, ManyfoldError):
+    """Manyfold refused the configured credentials, or none are configured."""
+
+    default_code = "manyfold_credentials"
+
+
+class ManyfoldForbiddenError(ProviderForbiddenError, ManyfoldError):
+    """Manyfold accepted the token but refused this request."""
+
+    default_code = "manyfold_forbidden"
+
+
+class ManyfoldNotFoundError(ProviderNotFoundError, ManyfoldError):
+    """The model or file doesn't exist, or isn't visible to the application's owner."""
+
+    default_code = "manyfold_not_found"
+
+
+class ManyfoldUnavailableError(ProviderUnavailableError, ManyfoldError):
+    """Manyfold is unreachable, failing, or sent something unexpected."""
+
+    default_code = "manyfold_failed"
+
+
+def clear_token_cache() -> None:
+    """Forget cached tokens and refusals, e.g. after the credentials were changed."""
+    _token_cache.clear()
+    _refused.clear()
+
+
+def valid_id(value: str) -> str:
+    """Return ``value`` if it is a well-formed Manyfold id, else raise."""
+    if not isinstance(value, str) or not _ID_RE.match(value):
+        raise ManyfoldNotFoundError("Not a valid Manyfold id")
+    return value
+
+
+def _id_from(url: Any, pattern: re.Pattern[str]) -> str | None:
+    if not isinstance(url, str):
+        return None
+    match = pattern.search(urlsplit(url).path)
+    return match.group(1) if match else None
+
+
+def _image_type(head: bytes) -> str | None:
+    for signature, mime in _IMAGE_SIGNATURES:
+        if head.startswith(signature):
+            return mime
+    if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
+        return "image/webp"
+    return None
+
+
+def is_importable_filename(filename: str) -> bool:
+    return filename.lower().endswith(IMPORTABLE_EXTENSIONS)
+
+
+class ManyfoldService(ProviderService):
+    """Per-request Manyfold client."""
+
+    def __init__(self, config: ManyfoldConfig, *, client: httpx.AsyncClient | None = None):
+        self._config = config
+        self._base = config.url.rstrip("/")
+        self._host = (urlsplit(self._base).hostname or "").lower()
+        if client is not None:
+            self._client = client
+            self._owns_client = False
+        else:
+            self._client = httpx.AsyncClient(timeout=30.0)
+            self._owns_client = True
+
+    async def close(self) -> None:
+        if self._owns_client:
+            await self._client.aclose()
+
+    # ---- sign-in -------------------------------------------------------
+
+    def _cache_key(self) -> tuple[str, str, str]:
+        digest = hashlib.sha256(self._config.client_secret.encode()).hexdigest()
+        return (self._base, self._config.client_id, digest)
+
+    async def _token(self, *, renew: bool = False) -> str:
+        if not self._config.configured:
+            raise ManyfoldAuthError(
+                "Manyfold is not set up. Enter its URL, client ID and secret first.", "manyfold_not_configured"
+            )
+        key = self._cache_key()
+        async with _sign_in_lock():
+            cached = _token_cache.get(key)
+            if cached and not renew and cached[1] > time.monotonic():
+                return cached[0]
+            refused = _refused.get(key)
+            if refused and refused[1] > time.monotonic():
+                earlier = refused[0]
+                raise type(earlier)(str(earlier), getattr(earlier, "code", None))
+            try:
+                return await self._sign_in(key)
+            except ManyfoldAuthError as exc:
+                _refused[key] = (exc, time.monotonic() + _REFUSED_FOR_SECONDS)
+                raise
+
+    async def _sign_in(self, key: tuple[str, str, str]) -> str:
+        """Ask Manyfold for a token; the caller holds the sign-in lock."""
+        try:
+            response = await self._client.post(
+                f"{self._base}/oauth/token",
+                data={
+                    "grant_type": "client_credentials",
+                    "client_id": self._config.client_id,
+                    "client_secret": self._config.client_secret,
+                    "scope": SCOPES,
+                },
+                headers={"Accept": "application/json"},
+            )
+        except httpx.HTTPError as exc:
+            raise ManyfoldUnavailableError(
+                f"Could not reach Manyfold at {self._base}: {exc}", "manyfold_unreachable"
+            ) from exc
+        if response.status_code == 429:
+            raise ManyfoldUnavailableError(
+                "Manyfold is limiting sign-ins. Try again in a few minutes.", "manyfold_rate_limited"
+            )
+        body = self._json_or_none(response)
+        error = body.get("error") if isinstance(body, dict) else None
+        if error == "invalid_scope":
+            raise ManyfoldAuthError(
+                "The Manyfold application lacks the 'read' scope. Edit it in Manyfold under Settings -> API.",
+                "manyfold_scope",
+            )
+        if response.status_code in (400, 401) or error in ("invalid_client", "unauthorized_client"):
+            raise ManyfoldAuthError("Manyfold did not accept the client ID or secret.")
+        if response.status_code != 200 or not isinstance(body, dict):
+            raise ManyfoldUnavailableError(f"Manyfold sign-in failed with HTTP {response.status_code}")
+        token = body.get("access_token")
+        if not isinstance(token, str) or not token:
+            raise ManyfoldUnavailableError("Manyfold sign-in returned no token")
+        granted = str(body.get("scope") or "").split()
+        if granted and "read" not in granted:
+            raise ManyfoldAuthError(
+                "The Manyfold application lacks the 'read' scope. Edit it in Manyfold under Settings -> API.",
+                "manyfold_scope",
+            )
+        try:
+            lifetime = float(body.get("expires_in") or 7200)
+        except (TypeError, ValueError):
+            lifetime = 7200.0
+        _token_cache[key] = (token, time.monotonic() + max(0.0, lifetime - _TOKEN_MARGIN_SECONDS))
+        return token
+
+    @staticmethod
+    def _json_or_none(response: httpx.Response) -> Any:
+        try:
+            return response.json()
+        except ValueError:
+            return None
+
+    # ---- requests ------------------------------------------------------
+
+    def _raise_for(self, response: httpx.Response, what: str) -> None:
+        status = response.status_code
+        if status == 401:
+            raise ManyfoldAuthError("Manyfold did not accept Bambuddy's sign-in.")
+        if status == 403:
+            raise ManyfoldForbiddenError(
+                f"Manyfold refused access to {what}. Check that the application has the 'read' scope "
+                "and that its owner can see this model."
+            )
+        if status == 404:
+            raise ManyfoldNotFoundError(f"Manyfold could not find {what}")
+        if status >= 400:
+            raise ManyfoldUnavailableError(f"Manyfold answered HTTP {status} for {what}")
+
+    async def _send(
+        self,
+        url: str,
+        *,
+        accept: str | None = None,
+        params: dict[str, Any] | None = None,
+        follow_redirects: bool = False,
+    ) -> httpx.Response:
+        """GET with the token, renewing it once if Manyfold refuses it.
+
+        The response is streamed; the caller reads and closes it.
+
+        Redirects (Manyfold may hand files over to object storage) are
+        followed here rather than by httpx, so every hop passes the same
+        LAN-service check as the configured URL, and the token is only sent
+        to Manyfold's own host.
+        """
+        headers = {"Accept": accept} if accept else {}
+        for attempt in range(2):
+            headers["Authorization"] = f"Bearer {await self._token(renew=attempt > 0)}"
+            try:
+                request = self._client.build_request("GET", url, headers=headers, params=params)
+                response = await self._client.send(request, stream=True)
+                hops = 0
+                while follow_redirects and response.is_redirect and response.next_request is not None:
+                    hops += 1
+                    following = response.next_request
+                    await response.aclose()
+                    if hops > _MAX_REDIRECTS:
+                        raise ManyfoldUnavailableError("Manyfold redirected too often")
+                    try:
+                        assert_safe_lan_service_url(str(following.url), label="Manyfold redirect")
+                    except ValueError as exc:
+                        raise ManyfoldUnavailableError(f"Refusing Manyfold's redirect: {exc}") from exc
+                    if (following.url.host or "").lower() != self._host:
+                        following.headers.pop("Authorization", None)
+                    response = await self._client.send(following, stream=True)
+            except httpx.HTTPError as exc:
+                raise ManyfoldUnavailableError(f"Could not reach Manyfold: {exc}", "manyfold_unreachable") from exc
+            # Only Manyfold's own 401 means the token was refused; object
+            # storage behind a redirect never saw it.
+            if response.status_code == 401 and attempt == 0 and (response.url.host or "").lower() == self._host:
+                await response.aclose()
+                continue
+            return response
+        return response  # pragma: no cover - the loop always returns
+
+    async def _get_json(self, path: str, what: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
+        response = await self._send(f"{self._base}{path}", accept=API_MEDIA_TYPE, params=params)
+        try:
+            await response.aread()
+        finally:
+            await response.aclose()
+        self._raise_for(response, what)
+        body = self._json_or_none(response)
+        if not isinstance(body, dict):
+            raise ManyfoldUnavailableError(f"Manyfold sent an unexpected answer for {what}")
+        return body
+
+    async def _read_capped(self, response: httpx.Response, cap: int, what: str) -> bytes:
+        chunks: list[bytes] = []
+        size = 0
+        async for chunk in response.aiter_bytes():
+            size += len(chunk)
+            if size > cap:
+                raise ManyfoldUnavailableError(f"{what} is larger than {cap // (1024 * 1024)} MB", "manyfold_too_large")
+            chunks.append(chunk)
+        return b"".join(chunks)
+
+    # ---- models --------------------------------------------------------
+
+    async def list_models(self, *, query: str = "", page: int = 1) -> dict[str, Any]:
+        params: dict[str, Any] = {"page": max(1, page)}
+        if query.strip():
+            params["q"] = query.strip()
+        body = await self._get_json("/models", "the model list", params)
+        members = body.get("member") if isinstance(body.get("member"), list) else []
+        models = []
+        for member in members:
+            if not isinstance(member, dict):
+                continue
+            model_id = _id_from(member.get("@id"), _MODEL_ID_IN_URL)
+            if model_id:
+                models.append({"id": model_id, "name": str(member.get("name") or model_id)})
+        view = body.get("view") if isinstance(body.get("view"), dict) else {}
+        total = body.get("totalItems")
+        return {
+            "total": total if isinstance(total, int) else len(models),
+            "page": params["page"],
+            "has_next": bool(view.get("next")),
+            "has_previous": bool(view.get("previous")),
+            "models": models,
+        }
+
+    async def get_model(self, model_id: str) -> dict[str, Any]:
+        model_id = valid_id(model_id)
+        body = await self._get_json(f"/models/{model_id}", "this model")
+        files = []
+        for part in body.get("hasPart") or []:
+            if not isinstance(part, dict):
+                continue
+            file_id = _id_from(part.get("@id"), _FILE_ID_IN_URL)
+            if not file_id:
+                continue
+            mime = str(part.get("encodingFormat") or "")
+            files.append(
+                {
+                    "id": file_id,
+                    "name": str(part.get("name") or file_id),
+                    "mime": mime,
+                    "importable": mime in IMPORTABLE_MIME_TYPES,
+                }
+            )
+        license_info = body.get("spdx:license")
+        keywords = body.get("keywords")
+        preview = body.get("preview_file")
+        return {
+            "id": model_id,
+            "name": str(body.get("name") or model_id),
+            "caption": body.get("caption") if isinstance(body.get("caption"), str) else None,
+            "description": body.get("description") if isinstance(body.get("description"), str) else None,
+            "license": license_info.get("licenseId") if isinstance(license_info, dict) else None,
+            "tags": [str(tag) for tag in keywords] if isinstance(keywords, list) else [],
+            "url": f"{self._base}/models/{model_id}",
+            "preview_file_id": _id_from(preview.get("@id"), _FILE_ID_IN_URL) if isinstance(preview, dict) else None,
+            "files": files,
+        }
+
+    async def get_file(self, model_id: str, file_id: str) -> dict[str, Any]:
+        """One file's details, with the raw-download path Manyfold gives for it."""
+        model_id, file_id = valid_id(model_id), valid_id(file_id)
+        body = await self._get_json(f"/models/{model_id}/model_files/{file_id}", "this file")
+        content_path = urlsplit(str(body.get("contentUrl") or "")).path
+        marker = f"/models/{model_id}/raw/"
+        position = content_path.find(marker)
+        tail = content_path[position + len(marker) :] if position >= 0 else ""
+        segments = unquote(tail).split("/")
+        if not tail or any(segment in ("", ".", "..") for segment in segments):
+            raise ManyfoldUnavailableError("Manyfold sent no usable download link for this file")
+        size = body.get("contentSize")
+        return {
+            "id": file_id,
+            "model_id": model_id,
+            "name": str(body.get("name") or file_id),
+            "filename": os.path.basename(unquote(tail)),
+            "mime": str(body.get("encodingFormat") or ""),
+            "size": size if isinstance(size, int) else None,
+            "raw_path": f"/models/{model_id}/raw/{tail}",
+        }
+
+    async def check(self) -> int:
+        """Sign in and read the first page of models; returns the model count."""
+        return int((await self.list_models())["total"])
+
+    # ---- files and previews ------------------------------------------
+
+    async def download_file(self, file: dict[str, Any]) -> bytes:
+        response = await self._send(f"{self._base}{file['raw_path']}", follow_redirects=True)
+        try:
+            self._raise_for(response, "this file")
+            return await self._read_capped(response, MAX_FILE_BYTES, "The file")
+        finally:
+            await response.aclose()
+
+    async def fetch_preview(self, model_id: str) -> tuple[bytes, str]:
+        """The model's preview as an image, from Manyfold's own derivatives.
+
+        An image preview comes as Manyfold's small "preview" copy, a 3D file
+        as its rendered picture when Manyfold made one. Without a picture
+        Manyfold sends the original file instead, so anything that doesn't
+        start like an image is refused after its first bytes.
+        """
+        model = await self.get_model(model_id)
+        file_id = model["preview_file_id"]
+        if not file_id:
+            raise ManyfoldNotFoundError("This model has no preview")
+        file = await self.get_file(model["id"], file_id)
+        extension = os.path.splitext(file["filename"])[1].lower()
+        mime = file["mime"]
+        if mime.startswith("image/"):
+            derivative = "preview"
+        elif mime.startswith("model/"):
+            derivative = "render"
+        else:
+            raise ManyfoldNotFoundError("This model has no preview")
+        if not re.fullmatch(r"\.[a-z0-9]{1,10}", extension):
+            raise ManyfoldNotFoundError("This model has no preview")
+        response = await self._send(
+            f"{self._base}/models/{model['id']}/model_files/{file_id}{extension}",
+            params={"derivative": derivative},
+            follow_redirects=True,
+        )
+        try:
+            self._raise_for(response, "the preview")
+            data = bytearray()
+            content_type: str | None = None
+            async for chunk in response.aiter_bytes():
+                data += chunk
+                if content_type is None and len(data) >= 12:
+                    content_type = _image_type(bytes(data[:12]))
+                    if content_type is None:
+                        raise ManyfoldNotFoundError("This model has no preview")
+                if len(data) > MAX_PREVIEW_BYTES:
+                    raise ManyfoldNotFoundError("This model's preview is too large")
+            content_type = content_type or _image_type(bytes(data[:12]))
+            if content_type is None:
+                raise ManyfoldNotFoundError("This model has no preview")
+            return bytes(data), content_type
+        finally:
+            await response.aclose()
+
+    # ---- ProviderService -----------------------------------------------
+
+    async def get_status(self, db) -> ProviderStatus:
+        configured = self._config.configured
+        return ProviderStatus(authenticated=configured, can_download=configured)
+
+    async def resolve(self, ref: ProviderResourceRef) -> ProviderResolvedModel:
+        model = await self.get_model(ref.external_id)
+        return ProviderResolvedModel(ref=ref, design=model, instances=model["files"])
+
+    async def get_download(self, ref: ProviderResourceRef) -> ProviderDownloadInfo:
+        if not ref.sub_id:
+            raise ManyfoldNotFoundError("Name the file to download")
+        file = await self.get_file(ref.external_id, ref.sub_id)
+        return ProviderDownloadInfo(ref=ref, url=f"{self._base}{file['raw_path']}", suggested_filename=file["filename"])
+
+    async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
+        if (urlsplit(info.url).hostname or "").lower() != self._host or not info.url.startswith(self._base + "/"):
+            raise ManyfoldUnavailableError("Refusing to download from outside the Manyfold install")
+        data = await self.download_file({"raw_path": info.url[len(self._base) :]})
+        return ProviderDownload(file_bytes=data, filename=info.suggested_filename)
+
+    async def fetch_thumbnail(self, url: str) -> tuple[bytes, str]:
+        model_id = _id_from(url, _MODEL_ID_IN_URL)
+        if (urlsplit(url).hostname or "").lower() != self._host or not model_id:
+            raise ManyfoldUnavailableError("Refusing to fetch an image from outside the Manyfold install")
+        return await self.fetch_preview(model_id)

+ 186 - 0
backend/tests/_fixtures/manyfold.py

@@ -0,0 +1,186 @@
+"""A fake Manyfold install for the Manyfold tests (#1471).
+
+Answers the requests Bambuddy makes the way Manyfold's v0 API does (read from
+Manyfold's source: the doorkeeper token endpoint, the JSON-LD serializers,
+``model_files#show`` with a derivative, ``model_files#raw``), through an
+``httpx.MockTransport``, and records what it was asked.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from urllib.parse import parse_qs, quote, unquote
+
+import httpx
+
+BASE = "http://manyfold.test:3214"
+API = "application/vnd.manyfold.v0+json"
+
+PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
+STL = b"solid cube\n" + b"facet normal 0 0 1\n" * 40 + b"endsolid cube\n"
+THREE_MF = b"PK\x03\x04" + b"\x00" * 64
+
+
+@dataclass
+class FakeFile:
+    filename: str
+    mime: str
+    data: bytes
+    name: str = ""
+    render: bytes | None = None
+
+
+@dataclass
+class FakeManyfold:
+    client_id: str = "app-id"
+    client_secret: str = "app-secret"
+    scopes: str = "public read"
+    page_size: int = 2
+    models: dict[str, dict] = field(default_factory=dict)
+    token_requests: int = 0
+    requests: list[httpx.Request] = field(default_factory=list)
+    valid_tokens: set[str] = field(default_factory=set)
+    token_lifetime: int = 7200
+
+    def add_model(self, model_id: str, name: str, files: dict[str, FakeFile], preview: str | None = None) -> None:
+        self.models[model_id] = {"name": name, "files": files, "preview": preview}
+
+    def revoke_tokens(self) -> None:
+        self.valid_tokens.clear()
+
+    def transport(self) -> httpx.MockTransport:
+        return httpx.MockTransport(self.handle)
+
+    def client(self) -> httpx.AsyncClient:
+        return httpx.AsyncClient(transport=self.transport())
+
+    # ---- handler ----
+
+    def handle(self, request: httpx.Request) -> httpx.Response:
+        self.requests.append(request)
+        path = unquote(request.url.raw_path.decode().split("?")[0])
+        if request.method == "POST" and path == "/oauth/token":
+            return self._token(request)
+        auth = request.headers.get("Authorization", "")
+        if not auth.startswith("Bearer ") or auth[7:] not in self.valid_tokens:
+            return httpx.Response(401, json={"error": "unauthorized"})
+        parts = [p for p in path.split("/") if p]
+        api = request.headers.get("Accept") == API
+        if parts == ["models"] and api:
+            return self._list(request)
+        if len(parts) == 2 and parts[0] == "models" and api:
+            return self._model(parts[1])
+        if len(parts) >= 4 and parts[0] == "models" and parts[2] == "raw":
+            return self._raw(parts[1], "/".join(parts[3:]))
+        if len(parts) == 4 and parts[0] == "models" and parts[2] == "model_files":
+            return self._file(parts[1], parts[3], api, request)
+        return httpx.Response(404)
+
+    def _token(self, request: httpx.Request) -> httpx.Response:
+        self.token_requests += 1
+        form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
+        if form.get("client_id") != self.client_id or form.get("client_secret") != self.client_secret:
+            return httpx.Response(401, json={"error": "invalid_client"})
+        wanted = set(form.get("scope", "").split())
+        if not wanted <= set(self.scopes.split()):
+            return httpx.Response(400, json={"error": "invalid_scope"})
+        token = f"token-{self.token_requests}"
+        self.valid_tokens.add(token)
+        return httpx.Response(
+            200,
+            json={
+                "access_token": token,
+                "token_type": "Bearer",
+                "expires_in": self.token_lifetime,
+                "scope": " ".join(sorted(wanted)),
+            },
+        )
+
+    def _list(self, request: httpx.Request) -> httpx.Response:
+        q = request.url.params.get("q", "")
+        page = int(request.url.params.get("page", "1"))
+        ids = [mid for mid, m in self.models.items() if q.lower() in m["name"].lower()]
+        start = (page - 1) * self.page_size
+        chunk = ids[start : start + self.page_size]
+        pages = max(1, -(-len(ids) // self.page_size))
+        view = {"@id": f"/models?page={page}", "first": "/models?page=1", "last": f"/models?page={pages}"}
+        if page > 1:
+            view["previous"] = f"/models?page={page - 1}"
+        if page < pages:
+            view["next"] = f"/models?page={page + 1}"
+        return httpx.Response(
+            200,
+            headers={"Content-Type": API},
+            content=json.dumps(
+                {
+                    "@id": "/models",
+                    "@type": "hydra:Collection",
+                    "totalItems": len(ids),
+                    "member": [
+                        {"@id": f"{BASE}/models/{mid}", "@type": "3DModel", "name": self.models[mid]["name"]}
+                        for mid in chunk
+                    ],
+                    "view": view,
+                }
+            ),
+        )
+
+    def _model(self, model_id: str) -> httpx.Response:
+        model = self.models.get(model_id)
+        if model is None:
+            return httpx.Response(404)
+        body = {
+            "@id": f"{BASE}/models/{model_id}",
+            "@type": "3DModel",
+            "name": model["name"],
+            "description": "A test model",
+            "spdx:license": {"@type": "spdx:License", "licenseId": "CC-BY-4.0"},
+            "keywords": ["test", "cube"],
+            "hasPart": [
+                {
+                    "@id": f"{BASE}/models/{model_id}/model_files/{fid}",
+                    "@type": "3DModel",
+                    "name": f.name or fid,
+                    "encodingFormat": f.mime,
+                }
+                for fid, f in model["files"].items()
+            ],
+        }
+        if model["preview"]:
+            body["preview_file"] = {
+                "@id": f"{BASE}/models/{model_id}/model_files/{model['preview']}",
+                "@type": "3DModel",
+            }
+        return httpx.Response(200, headers={"Content-Type": API}, content=json.dumps(body))
+
+    def _file(self, model_id: str, file_part: str, api: bool, request: httpx.Request) -> httpx.Response:
+        file_id = file_part.split(".", 1)[0]
+        f = self.models.get(model_id, {}).get("files", {}).get(file_id)
+        if f is None:
+            return httpx.Response(404)
+        if api:
+            return httpx.Response(
+                200,
+                headers={"Content-Type": API},
+                content=json.dumps(
+                    {
+                        "@id": f"{BASE}/models/{model_id}/model_files/{file_id}",
+                        "name": f.name or file_id,
+                        "contentUrl": f"{BASE}/models/{model_id}/raw/{quote(f.filename)}",
+                        "encodingFormat": f.mime,
+                        "contentSize": len(f.data),
+                    }
+                ),
+            )
+        derivative = request.url.params.get("derivative")
+        if derivative and f.render is not None:
+            return httpx.Response(200, content=f.render)
+        # Like Manyfold: no derivative means the original file.
+        return httpx.Response(200, content=f.data)
+
+    def _raw(self, model_id: str, filename: str) -> httpx.Response:
+        for f in self.models.get(model_id, {}).get("files", {}).values():
+            if f.filename == filename:
+                return httpx.Response(200, content=f.data)
+        return httpx.Response(404)

+ 378 - 0
backend/tests/integration/test_manyfold_api.py

@@ -0,0 +1,378 @@
+"""The /manyfold routes (#1471), against a fake Manyfold install."""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.api.routes import manyfold as routes
+from backend.app.core import database as _database_module
+from backend.app.core.database import seed_default_groups
+from backend.app.models.group import Group
+from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.settings import Settings
+from backend.app.services.model_providers.manyfold import service as svc
+from backend.tests._fixtures.manyfold import BASE, PNG, STL, THREE_MF, FakeFile, FakeManyfold
+
+API = "/api/v1/manyfold"
+SECRET = "app-secret"
+
+
+@pytest.fixture
+def manyfold(monkeypatch) -> FakeManyfold:
+    fake = FakeManyfold(client_secret=SECRET)
+    fake.add_model(
+        "cube01",
+        "Calibration Cube",
+        {
+            "f1": FakeFile("cube.stl", "model/stl", STL, render=PNG),
+            "f2": FakeFile("cube.3mf", "model/3mf", THREE_MF),
+            "f4": FakeFile("notes.pdf", "application/pdf", b"%PDF-1.7"),
+            "f5": FakeFile("broken.3mf", "model/3mf", b"not a zip at all"),
+            "f6": FakeFile("bad:name?.stl", "model/stl", STL),
+        },
+        preview="f1",
+    )
+    fake.add_model("boat02", "Benchy", {"f9": FakeFile("benchy.stl", "model/stl", STL)})
+    real = routes.ManyfoldService
+    monkeypatch.setattr(routes, "ManyfoldService", lambda config: real(config, client=fake.client()))
+    svc.clear_token_cache()
+    yield fake
+    svc.clear_token_cache()
+
+
+async def _connect(client: AsyncClient, fake: FakeManyfold, headers: dict | None = None) -> None:
+    resp = await client.put(
+        f"{API}/config",
+        json={"url": f"{BASE}/", "client_id": fake.client_id, "client_secret": SECRET},
+        headers=headers or {},
+    )
+    assert resp.status_code == 200, resp.text
+
+
+class TestConnection:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_store_read_and_disconnect_without_ever_returning_the_secret(self, async_client, manyfold):
+        empty = (await async_client.get(f"{API}/config")).json()
+        assert empty == {"url": "", "client_id": "", "has_client_secret": False, "configured": False}
+
+        await _connect(async_client, manyfold)
+        stored = await async_client.get(f"{API}/config")
+        assert stored.json() == {
+            "url": BASE,
+            "client_id": "app-id",
+            "has_client_secret": True,
+            "configured": True,
+        }
+        for response in (stored, await async_client.get("/api/v1/settings/")):
+            # The stored rows must not break the general settings response either.
+            assert response.status_code == 200, response.text
+            assert SECRET not in response.text
+
+        assert (await async_client.delete(f"{API}/config")).status_code == 204
+        assert (await async_client.get(f"{API}/config")).json()["configured"] is False
+        assert (await async_client.get(f"{API}/status")).json() == {"configured": False, "url": ""}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_empty_secret_keeps_the_stored_one(self, async_client, manyfold, db_session):
+        await _connect(async_client, manyfold)
+        resp = await async_client.put(f"{API}/config", json={"url": BASE, "client_id": "app-id", "client_secret": ""})
+        assert resp.status_code == 200
+        row = (await db_session.execute(select(Settings).where(Settings.key == "manyfold_client_secret"))).scalar_one()
+        assert row.value == SECRET
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_first_save_needs_a_secret(self, async_client, manyfold):
+        resp = await async_client.put(f"{API}/config", json={"url": BASE, "client_id": "app-id"})
+        assert resp.status_code == 400
+        assert resp.json()["detail"]["code"] == "manyfold_secret_required"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("url", ["docker:3214", "http://169.254.169.254/"])
+    async def test_bad_url(self, async_client, manyfold, url):
+        for path, method in ((f"{API}/config", "put"), (f"{API}/config/test", "post")):
+            resp = await getattr(async_client, method)(path, json={"url": url, "client_id": "a", "client_secret": "b"})
+            assert resp.status_code == 400
+            assert resp.json()["detail"]["code"] == "manyfold_bad_url"
+        assert manyfold.requests == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_test_counts_models_and_stores_nothing(self, async_client, manyfold):
+        resp = await async_client.post(
+            f"{API}/config/test", json={"url": BASE, "client_id": "app-id", "client_secret": SECRET}
+        )
+        assert resp.status_code == 200, resp.text
+        assert resp.json() == {"model_count": 2}
+        assert (await async_client.get(f"{API}/config")).json()["configured"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_refused_secret_is_not_answered_with_401(self, async_client, manyfold):
+        # A 401 would read as "your Bambuddy session ended" to the frontend.
+        resp = await async_client.post(
+            f"{API}/config/test", json={"url": BASE, "client_id": "app-id", "client_secret": "wrong"}
+        )
+        assert resp.status_code == 502
+        assert resp.json()["detail"]["code"] == "manyfold_credentials"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_test_with_an_empty_secret_uses_the_stored_one(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        resp = await async_client.post(f"{API}/config/test", json={"url": BASE, "client_id": "app-id"})
+        assert resp.status_code == 200, resp.text
+
+
+class TestBrowsing:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_configured_is_409(self, async_client, manyfold):
+        resp = await async_client.get(f"{API}/models")
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["code"] == "manyfold_not_configured"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_search_and_details(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        assert (await async_client.get(f"{API}/status")).json() == {"configured": True, "url": BASE}
+        listing = (await async_client.get(f"{API}/models")).json()
+        assert [m["name"] for m in listing["models"]] == ["Calibration Cube", "Benchy"]
+        found = (await async_client.get(f"{API}/models", params={"q": "bench"})).json()
+        assert found["total"] == 1
+
+        model = (await async_client.get(f"{API}/models/cube01")).json()
+        assert model["has_preview"] is True
+        assert model["url"] == f"{BASE}/models/cube01"
+        assert {f["id"]: f["importable"] for f in model["files"]} == {
+            "f1": True,
+            "f2": True,
+            "f4": False,
+            "f5": True,
+            "f6": True,
+        }
+        assert all(f["library_file"] is None for f in model["files"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_missing_model_and_malformed_id(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        assert (await async_client.get(f"{API}/models/gone99")).status_code == 404
+        assert (await async_client.get(f"{API}/models/a.b")).status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preview(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        resp = await async_client.get(f"{API}/models/cube01/preview")
+        assert resp.status_code == 200
+        assert resp.content == PNG
+        assert resp.headers["content-type"] == "image/png"
+        assert (await async_client.get(f"{API}/models/boat02/preview")).status_code == 404
+
+
+class TestImport:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_makerworld_import_route_does_not_serve_manyfold(self, async_client, manyfold):
+        # Manyfold's own route checks file types and names; the pasted-URL route must not bypass that.
+        await _connect(async_client, manyfold)
+        resp = await async_client.post(
+            "/api/v1/makerworld/import", json={"model_id": 1, "profile_id": 2, "source_type": "manyfold"}
+        )
+        assert resp.status_code == 400
+        assert manyfold.requests == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_underscore_in_an_id_matches_only_itself(self, async_client, manyfold):
+        # "_" is LIKE's single-character wildcard: model a_c must not see abc's import.
+        manyfold.add_model("abc", "ABC", {"f1": FakeFile("abc.stl", "model/stl", STL)})
+        manyfold.add_model("a_c", "A C", {"f1": FakeFile("ac.stl", "model/stl", STL)})
+        await _connect(async_client, manyfold)
+        assert (await async_client.post(f"{API}/import", json={"model_id": "abc", "file_id": "f1"})).status_code == 200
+        other = (await async_client.get(f"{API}/models/a_c")).json()
+        assert other["files"][0]["library_file"] is None
+        mine = (await async_client.get(f"{API}/models/abc")).json()
+        assert mine["files"][0]["library_file"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_lands_in_the_manyfold_folder_once(self, async_client, manyfold, db_session):
+        await _connect(async_client, manyfold)
+        resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f1"})
+        assert resp.status_code == 200, resp.text
+        first = resp.json()
+        assert first["filename"] == "cube.stl" and first["was_existing"] is False
+
+        row = await db_session.get(LibraryFile, first["library_file_id"])
+        folder = await db_session.get(LibraryFolder, row.folder_id)
+        assert folder.name == "Manyfold" and folder.parent_id is None
+        assert row.source_type == "manyfold"
+        assert row.source_url == "manyfold:cube01/f1"
+        assert row.file_type == "stl"
+
+        downloads = sum(1 for r in manyfold.requests if "/raw/" in r.url.path)
+        again = (await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f1"})).json()
+        assert again == {**first, "was_existing": True}
+        assert sum(1 for r in manyfold.requests if "/raw/" in r.url.path) == downloads
+
+        model = (await async_client.get(f"{API}/models/cube01")).json()
+        assert {f["id"]: f["library_file"] for f in model["files"]}["f1"] == {
+            "id": first["library_file_id"],
+            "filename": "cube.stl",
+            "folder_id": first["folder_id"],
+        }
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_deleted_import_can_be_imported_again(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        first = (await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f2"})).json()
+        assert (await async_client.delete(f"/api/v1/library/files/{first['library_file_id']}")).status_code in (
+            200,
+            204,
+        )
+        again = (await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f2"})).json()
+        assert again["was_existing"] is False
+        assert again["library_file_id"] != first["library_file_id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_only_printable_files(self, async_client, manyfold, db_session):
+        await _connect(async_client, manyfold)
+        resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f4"})
+        assert resp.status_code == 400
+        assert resp.json()["detail"]["code"] == "manyfold_not_importable"
+        # Refused before anything was created.
+        folders = (await db_session.execute(select(LibraryFolder).where(LibraryFolder.name == "Manyfold"))).all()
+        assert folders == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_3mf_that_is_not_a_zip_is_refused(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f5"})
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_names_the_printer_cannot_store_are_cleaned(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f6"})
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["filename"] == "bad_name_.stl"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_into_a_chosen_folder(self, async_client, manyfold):
+        await _connect(async_client, manyfold)
+        folder = (await async_client.post("/api/v1/library/folders", json={"name": "Calibration"})).json()
+        resp = await async_client.post(
+            f"{API}/import", json={"model_id": "boat02", "file_id": "f9", "folder_id": folder["id"]}
+        )
+        assert resp.json()["folder_id"] == folder["id"]
+
+
+# ---- permissions -------------------------------------------------------
+
+
+async def _admin(client: AsyncClient) -> dict:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "mfadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": "mfadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _user(client: AsyncClient, admin: dict, username: str, permissions: list[str]) -> dict:
+    group = await client.post(
+        "/api/v1/groups/", headers=admin, json={"name": f"g_{username}", "permissions": permissions}
+    )
+    assert group.status_code in (200, 201), group.text
+    created = await client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+class TestPermissions:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_view_only_can_browse_but_not_import_or_configure(self, async_client, manyfold):
+        admin = await _admin(async_client)
+        await _connect(async_client, manyfold, admin)
+        viewer = await _user(async_client, admin, "mfviewer", ["manyfold:view", "settings:read"])
+
+        assert (await async_client.get(f"{API}/models", headers=viewer)).status_code == 200
+        resp = await async_client.post(f"{API}/import", headers=viewer, json={"model_id": "cube01", "file_id": "f1"})
+        assert resp.status_code == 403
+        resp = await async_client.put(
+            f"{API}/config", headers=viewer, json={"url": BASE, "client_id": "x", "client_secret": "y"}
+        )
+        assert resp.status_code == 403
+        assert (await async_client.delete(f"{API}/config", headers=viewer)).status_code == 403
+        assert SECRET not in (await async_client.get(f"{API}/config", headers=viewer)).text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_makerworld_access_alone_does_not_open_manyfold(self, async_client, manyfold):
+        admin = await _admin(async_client)
+        await _connect(async_client, manyfold, admin)
+        mw = await _user(async_client, admin, "mwonly", ["makerworld:view", "makerworld:import"])
+        assert (await async_client.get(f"{API}/models", headers=mw)).status_code == 403
+        assert (await async_client.get(f"{API}/status", headers=mw)).status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_preview_is_refused(self, async_client, manyfold):
+        admin = await _admin(async_client)
+        await _connect(async_client, manyfold, admin)
+        assert (await async_client.get(f"{API}/models/cube01/preview")).status_code == 401
+
+
+class TestPermissionBackfill:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_groups_get_what_they_have_on_makerworld_once(self, async_client):
+        async with _database_module.async_session() as session:
+            await session.execute(
+                Settings.__table__.delete().where(Settings.key == "_backfill_1471_manyfold_permissions_done")
+            )
+            for name, perms in (
+                ("mf_importers", ["library:read_own", "makerworld:view", "makerworld:import"]),
+                ("mf_browsers", ["makerworld:view"]),
+                ("mf_nothing", ["library:read_own"]),
+            ):
+                session.add(Group(name=name, permissions=perms))
+            await session.commit()
+
+        await seed_default_groups()
+
+        async with _database_module.async_session() as session:
+            groups = {g.name: set(g.permissions) for g in (await session.execute(select(Group))).scalars().all()}
+        assert {"manyfold:view", "manyfold:import"} <= groups["mf_importers"]
+        assert "manyfold:view" in groups["mf_browsers"] and "manyfold:import" not in groups["mf_browsers"]
+        assert not {"manyfold:view", "manyfold:import"} & groups["mf_nothing"]
+
+        # An admin takes it away again; the next start must not hand it back.
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "mf_browsers"))).scalar_one()
+            grp.permissions = ["makerworld:view"]
+            await session.commit()
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "mf_browsers"))).scalar_one()
+            assert grp.permissions == ["makerworld:view"]

+ 418 - 0
backend/tests/unit/services/test_manyfold_service.py

@@ -0,0 +1,418 @@
+"""The Manyfold client (#1471), against a fake Manyfold install."""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from backend.app.services.model_providers.manyfold import service as svc
+from backend.app.services.model_providers.manyfold.config import ManyfoldConfig, normalize_url
+from backend.app.services.model_providers.manyfold.service import (
+    ManyfoldAuthError,
+    ManyfoldNotFoundError,
+    ManyfoldService,
+    ManyfoldUnavailableError,
+)
+from backend.tests._fixtures.manyfold import BASE, PNG, STL, THREE_MF, FakeFile, FakeManyfold
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture(autouse=True)
+def fresh_tokens():
+    svc.clear_token_cache()
+    yield
+    svc.clear_token_cache()
+
+
+@pytest.fixture
+def manyfold() -> FakeManyfold:
+    fake = FakeManyfold()
+    fake.add_model(
+        "cube01",
+        "Calibration Cube",
+        {
+            "f1": FakeFile("cube.stl", "model/stl", STL, render=PNG),
+            "f2": FakeFile("cube.3mf", "model/3mf", THREE_MF),
+            "f3": FakeFile("photo.jpg", "image/jpeg", b"\xff\xd8\xff" + b"\x00" * 40),
+            "f4": FakeFile("notes.pdf", "application/pdf", b"%PDF-1.7"),
+        },
+        preview="f1",
+    )
+    fake.add_model("boat02", "Benchy", {"f9": FakeFile("benchy.stl", "model/stl", STL)})
+    fake.add_model("vase03", "Spiral Vase", {})
+    return fake
+
+
+def _service(fake: FakeManyfold, **overrides) -> ManyfoldService:
+    config = ManyfoldConfig(
+        url=overrides.get("url", BASE),
+        client_id=overrides.get("client_id", fake.client_id),
+        client_secret=overrides.get("client_secret", fake.client_secret),
+    )
+    return ManyfoldService(config, client=fake.client())
+
+
+class TestSignIn:
+    @pytest.mark.asyncio
+    async def test_one_token_serves_many_requests(self, manyfold):
+        # Manyfold allows 10 sign-ins in 3 minutes; a page of previews must not use them up.
+        service = _service(manyfold)
+        for _ in range(5):
+            await service.list_models()
+        await _service(manyfold).get_model("cube01")
+        assert manyfold.token_requests == 1
+
+    @pytest.mark.asyncio
+    async def test_asks_only_for_read_access(self, manyfold):
+        await _service(manyfold).list_models()
+        token_request = next(r for r in manyfold.requests if r.url.path == "/oauth/token")
+        assert "scope=public+read" in token_request.content.decode()
+        assert "grant_type=client_credentials" in token_request.content.decode()
+
+    @pytest.mark.asyncio
+    async def test_a_refused_token_is_renewed_once(self, manyfold):
+        service = _service(manyfold)
+        await service.list_models()
+        manyfold.revoke_tokens()  # e.g. Manyfold restarted, or the token was revoked
+        result = await service.list_models()
+        assert result["total"] == 3
+        assert manyfold.token_requests == 2
+
+    @pytest.mark.asyncio
+    async def test_an_expired_token_is_renewed_before_use(self, manyfold):
+        manyfold.token_lifetime = 60  # shorter than the renewal margin
+        service = _service(manyfold)
+        await service.list_models()
+        await service.list_models()
+        assert manyfold.token_requests == 2
+
+    @pytest.mark.asyncio
+    async def test_wrong_secret(self, manyfold):
+        with pytest.raises(ManyfoldAuthError) as err:
+            await _service(manyfold, client_secret="nope").list_models()
+        assert err.value.code == "manyfold_credentials"
+
+    @pytest.mark.asyncio
+    async def test_a_refused_secret_is_not_tried_again_at_once(self, manyfold):
+        # A page or a bulk import must not use up Manyfold's 10 sign-ins in 3 minutes.
+        for _ in range(5):
+            with pytest.raises(ManyfoldAuthError) as err:
+                await _service(manyfold, client_secret="nope").list_models()
+            assert err.value.code == "manyfold_credentials"
+        assert manyfold.token_requests == 1
+        # The right secret is a different sign-in and goes through at once.
+        assert (await _service(manyfold).list_models())["total"] == 3
+        assert manyfold.token_requests == 2
+
+    @pytest.mark.asyncio
+    async def test_saving_the_connection_forgets_a_refusal(self, manyfold):
+        with pytest.raises(ManyfoldAuthError):
+            await _service(manyfold, client_secret="nope").list_models()
+        svc.clear_token_cache()
+        with pytest.raises(ManyfoldAuthError):
+            await _service(manyfold, client_secret="nope").list_models()
+        assert manyfold.token_requests == 2
+
+    @pytest.mark.asyncio
+    async def test_a_401_from_object_storage_does_not_renew_the_token(self, manyfold):
+        def answer(request: httpx.Request) -> httpx.Response:
+            if request.url.host == "storage.example.com":
+                return httpx.Response(401)
+            if request.url.path == "/models/cube01/raw/cube.stl":
+                return httpx.Response(302, headers={"Location": "https://storage.example.com/cube.stl"})
+            return manyfold.handle(request)
+
+        service = ManyfoldService(
+            ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
+            client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
+        )
+        file = await service.get_file("cube01", "f1")
+        with pytest.raises(ManyfoldAuthError):
+            await service.download_file(file)
+        assert manyfold.token_requests == 1
+
+    @pytest.mark.asyncio
+    async def test_application_without_read_scope(self, manyfold):
+        manyfold.scopes = "public upload"
+        with pytest.raises(ManyfoldAuthError) as err:
+            await _service(manyfold).list_models()
+        assert err.value.code == "manyfold_scope"
+
+    @pytest.mark.asyncio
+    async def test_rate_limited_sign_in(self):
+        transport = httpx.MockTransport(lambda request: httpx.Response(429))
+        service = ManyfoldService(ManyfoldConfig(BASE, "a", "b"), client=httpx.AsyncClient(transport=transport))
+        with pytest.raises(ManyfoldUnavailableError) as err:
+            await service.list_models()
+        assert err.value.code == "manyfold_rate_limited"
+
+    @pytest.mark.asyncio
+    async def test_unreachable(self):
+        def refuse(request):
+            raise httpx.ConnectError("connection refused")
+
+        service = ManyfoldService(
+            ManyfoldConfig(BASE, "a", "b"), client=httpx.AsyncClient(transport=httpx.MockTransport(refuse))
+        )
+        with pytest.raises(ManyfoldUnavailableError) as err:
+            await service.list_models()
+        assert err.value.code == "manyfold_unreachable"
+
+    @pytest.mark.asyncio
+    async def test_not_configured(self, manyfold):
+        with pytest.raises(ManyfoldAuthError) as err:
+            await _service(manyfold, client_secret="").list_models()
+        assert err.value.code == "manyfold_not_configured"
+        assert manyfold.requests == []
+
+    @pytest.mark.asyncio
+    async def test_changed_credentials_get_their_own_token(self, manyfold):
+        await _service(manyfold).list_models()
+        other = FakeManyfold(client_id="other-id", client_secret="other-secret")
+        other.models = manyfold.models
+        await _service(other).list_models()
+        assert other.token_requests == 1
+
+
+class TestBrowsing:
+    @pytest.mark.asyncio
+    async def test_list_pages_and_search(self, manyfold):
+        service = _service(manyfold)
+        first = await service.list_models()
+        assert first == {
+            "total": 3,
+            "page": 1,
+            "has_next": True,
+            "has_previous": False,
+            "models": [{"id": "cube01", "name": "Calibration Cube"}, {"id": "boat02", "name": "Benchy"}],
+        }
+        second = await service.list_models(page=2)
+        assert second["models"] == [{"id": "vase03", "name": "Spiral Vase"}]
+        assert second["has_previous"] and not second["has_next"]
+        found = await service.list_models(query="  bench ")
+        assert [m["id"] for m in found["models"]] == ["boat02"]
+        assert manyfold.requests[-1].url.params["q"] == "bench"
+
+    @pytest.mark.asyncio
+    async def test_model_details(self, manyfold):
+        model = await _service(manyfold).get_model("cube01")
+        assert model["name"] == "Calibration Cube"
+        assert model["license"] == "CC-BY-4.0"
+        assert model["tags"] == ["test", "cube"]
+        assert model["url"] == f"{BASE}/models/cube01"
+        assert model["preview_file_id"] == "f1"
+        assert [(f["id"], f["importable"]) for f in model["files"]] == [
+            ("f1", True),
+            ("f2", True),
+            ("f3", False),
+            ("f4", False),
+        ]
+
+    @pytest.mark.asyncio
+    async def test_missing_model(self, manyfold):
+        with pytest.raises(ManyfoldNotFoundError):
+            await _service(manyfold).get_model("gone99")
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("bad", ["../etc", "a/b", "", "x" * 65, "a b", "a?b=1"])
+    async def test_malformed_ids_never_reach_manyfold(self, manyfold, bad):
+        with pytest.raises(ManyfoldNotFoundError):
+            await _service(manyfold).get_model(bad)
+        assert manyfold.requests == []
+
+    @pytest.mark.asyncio
+    async def test_sub_path_installs(self, manyfold):
+        # Behind a reverse proxy at /manyfold: every request keeps the prefix.
+        seen = []
+
+        def strip_prefix(request: httpx.Request) -> httpx.Response:
+            seen.append(request.url.path)
+            stripped = request.url.copy_with(raw_path=request.url.raw_path.replace(b"/manyfold", b"", 1))
+            return manyfold.handle(
+                httpx.Request(request.method, stripped, headers=request.headers, content=request.content)
+            )
+
+        service = ManyfoldService(
+            ManyfoldConfig(f"{BASE}/manyfold", manyfold.client_id, manyfold.client_secret),
+            client=httpx.AsyncClient(transport=httpx.MockTransport(strip_prefix)),
+        )
+        await service.get_model("cube01")
+        assert seen and all(path.startswith("/manyfold/") for path in seen)
+
+
+class TestFiles:
+    @pytest.mark.asyncio
+    async def test_download_uses_the_raw_link(self, manyfold):
+        service = _service(manyfold)
+        file = await service.get_file("cube01", "f1")
+        assert file["filename"] == "cube.stl"
+        assert file["raw_path"] == "/models/cube01/raw/cube.stl"
+        assert await service.download_file(file) == STL
+
+    @pytest.mark.asyncio
+    async def test_names_with_spaces_and_folders(self, manyfold):
+        manyfold.add_model("parts04", "Parts", {"p1": FakeFile("sub dir/My Part.stl", "model/stl", STL)})
+        service = _service(manyfold)
+        file = await service.get_file("parts04", "p1")
+        assert file["filename"] == "My Part.stl"
+        assert await service.download_file(file) == STL
+
+    @pytest.mark.asyncio
+    async def test_a_download_link_pointing_elsewhere_is_not_followed(self, manyfold):
+        # contentUrl is only read for the path under /models/<id>/raw/.
+        def answer(request: httpx.Request) -> httpx.Response:
+            if request.url.path == "/models/cube01/model_files/f1":
+                return httpx.Response(200, json={"contentUrl": "http://evil.test/models/other/raw/x.stl", "name": "x"})
+            return manyfold.handle(request)
+
+        service = ManyfoldService(
+            ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
+            client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
+        )
+        with pytest.raises(ManyfoldUnavailableError):
+            await service.get_file("cube01", "f1")
+
+    @pytest.mark.asyncio
+    async def test_dot_dot_in_the_link_is_refused(self, manyfold):
+        def answer(request: httpx.Request) -> httpx.Response:
+            if request.url.path == "/models/cube01/model_files/f1":
+                return httpx.Response(200, json={"contentUrl": f"{BASE}/models/cube01/raw/..%2F..%2Fsecrets"})
+            return manyfold.handle(request)
+
+        service = ManyfoldService(
+            ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
+            client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
+        )
+        with pytest.raises(ManyfoldUnavailableError):
+            await service.get_file("cube01", "f1")
+
+    @pytest.mark.asyncio
+    async def test_object_storage_redirect_is_followed_without_the_token(self, manyfold):
+        # Manyfold on S3-style storage answers a download with a redirect.
+        seen: list[httpx.Request] = []
+
+        def answer(request: httpx.Request) -> httpx.Response:
+            seen.append(request)
+            if request.url.host == "storage.example.com":
+                return httpx.Response(200, content=STL)
+            if request.url.path == "/models/cube01/raw/cube.stl":
+                return httpx.Response(302, headers={"Location": "https://storage.example.com/bucket/cube.stl?sig=x"})
+            return manyfold.handle(request)
+
+        service = ManyfoldService(
+            ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
+            client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
+        )
+        file = await service.get_file("cube01", "f1")
+        assert await service.download_file(file) == STL
+        storage = [r for r in seen if r.url.host == "storage.example.com"]
+        assert storage and "Authorization" not in storage[0].headers
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "target", ["http://169.254.169.254/latest/meta-data/", "http://metadata.google.internal/", "file:///etc/passwd"]
+    )
+    async def test_a_redirect_to_a_dangerous_target_is_refused(self, manyfold, target):
+        seen: list[httpx.Request] = []
+
+        def answer(request: httpx.Request) -> httpx.Response:
+            seen.append(request)
+            if request.url.path == "/models/cube01/raw/cube.stl":
+                return httpx.Response(302, headers={"Location": target})
+            return manyfold.handle(request)
+
+        service = ManyfoldService(
+            ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
+            client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
+        )
+        file = await service.get_file("cube01", "f1")
+        with pytest.raises(ManyfoldUnavailableError):
+            await service.download_file(file)
+        assert all(r.url.host == "manyfold.test" for r in seen)
+
+    @pytest.mark.asyncio
+    async def test_endless_redirects_stop(self, manyfold):
+        def answer(request: httpx.Request) -> httpx.Response:
+            if "/raw/" in request.url.path:
+                return httpx.Response(302, headers={"Location": f"{BASE}/models/cube01/raw/cube.stl"})
+            return manyfold.handle(request)
+
+        service = ManyfoldService(
+            ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
+            client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
+        )
+        file = await service.get_file("cube01", "f1")
+        with pytest.raises(ManyfoldUnavailableError):
+            await service.download_file(file)
+
+    @pytest.mark.asyncio
+    async def test_oversized_file_is_refused(self, manyfold, monkeypatch):
+        monkeypatch.setattr(svc, "MAX_FILE_BYTES", 50)
+        service = _service(manyfold)
+        file = await service.get_file("cube01", "f1")
+        with pytest.raises(ManyfoldUnavailableError) as err:
+            await service.download_file(file)
+        assert err.value.code == "manyfold_too_large"
+
+
+class TestPreviews:
+    @pytest.mark.asyncio
+    async def test_rendered_preview_of_a_3d_file(self, manyfold):
+        data, content_type = await _service(manyfold).fetch_preview("cube01")
+        assert (data, content_type) == (PNG, "image/png")
+        preview_request = manyfold.requests[-1]
+        assert preview_request.url.path == "/models/cube01/model_files/f1.stl"
+        assert preview_request.url.params["derivative"] == "render"
+
+    @pytest.mark.asyncio
+    async def test_image_preview_asks_for_the_small_copy(self, manyfold):
+        manyfold.models["cube01"]["preview"] = "f3"
+        data, content_type = await _service(manyfold).fetch_preview("cube01")
+        assert content_type == "image/jpeg"
+        assert manyfold.requests[-1].url.params["derivative"] == "preview"
+
+    @pytest.mark.asyncio
+    async def test_no_render_means_no_preview_not_the_whole_stl(self, manyfold):
+        # Without a render Manyfold sends the original file: refuse it, don't pass an STL off as an image.
+        manyfold.models["cube01"]["files"]["f1"].render = None
+        with pytest.raises(ManyfoldNotFoundError):
+            await _service(manyfold).fetch_preview("cube01")
+
+    @pytest.mark.asyncio
+    async def test_model_without_preview(self, manyfold):
+        with pytest.raises(ManyfoldNotFoundError):
+            await _service(manyfold).fetch_preview("boat02")
+
+
+class TestUrl:
+    @pytest.mark.parametrize(
+        ("raw", "expected"),
+        [
+            ("http://docker:3214", "http://docker:3214"),
+            ("  https://models.example.com/ ", "https://models.example.com"),
+            ("https://example.com/manyfold/", "https://example.com/manyfold"),
+        ],
+    )
+    def test_accepted(self, raw, expected):
+        assert normalize_url(raw) == expected
+
+    @pytest.mark.parametrize(
+        "raw",
+        [
+            "docker:3214",
+            "ftp://docker",
+            "http://",
+            "https://user:pw@host",
+            "http://host/?a=1",
+            "http://host/#x",
+            "",
+            # The LAN-service tier: never a Manyfold, under any topology.
+            "http://169.254.169.254/",
+            "http://metadata.google.internal/",
+            "http://2130706433/",
+        ],
+    )
+    def test_refused(self, raw):
+        with pytest.raises(ValueError):
+            normalize_url(raw)

+ 4 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -578,6 +578,10 @@ GUARDED_BODY_URLS = {
     ("HATestConnectionRequest", "url"),  # homeassistant._validate_url
     ("HATestConnectionRequest", "url"),  # homeassistant._validate_url
     ("RESTTestConnectionRequest", "url"),  # rest_smart_plug._validate_url
     ("RESTTestConnectionRequest", "url"),  # rest_smart_plug._validate_url
     ("TestConnectionRequest", "url"),  # obico_detection.test_connection
     ("TestConnectionRequest", "url"),  # obico_detection.test_connection
+    # Manyfold (#1471): manyfold.config.normalize_url applies the LAN tier on
+    # save and test, and ManyfoldService._send re-checks every redirect hop.
+    ("ManyfoldConfigUpdate", "url"),
+    ("ManyfoldTestRequest", "url"),
     ("OIDCProviderCreate", "issuer_url"),  # public tier, via schemas.auth
     ("OIDCProviderCreate", "issuer_url"),  # public tier, via schemas.auth
     ("OIDCProviderCreate", "icon_url"),
     ("OIDCProviderCreate", "icon_url"),
     ("OIDCProviderUpdate", "issuer_url"),
     ("OIDCProviderUpdate", "issuer_url"),

+ 7 - 0
backend/tests/unit/test_support_helpers.py

@@ -688,6 +688,10 @@ class TestCollectSupportInfo:
             # named without "auth_key" but whose value starts with the Tailscale
             # named without "auth_key" but whose value starts with the Tailscale
             # prefix must still redact.
             # prefix must still redact.
             MagicMock(key="some_future_ts_setting", value="tskey-other-secret"),
             MagicMock(key="some_future_ts_setting", value="tskey-other-secret"),
+            # The Manyfold connection (#1471): all three halves of the login.
+            MagicMock(key="manyfold_url", value="http://192.168.1.10:3214"),
+            MagicMock(key="manyfold_client_id", value="app-id"),
+            MagicMock(key="manyfold_client_secret", value="app-secret"),
         ]
         ]
 
 
         def make_result(rows=None):
         def make_result(rows=None):
@@ -736,6 +740,9 @@ class TestCollectSupportInfo:
         assert s.get("mqtt_broker") == "[REDACTED]"
         assert s.get("mqtt_broker") == "[REDACTED]"
         assert s.get("virtual_printer_tailscale_auth_key") == "[REDACTED]"
         assert s.get("virtual_printer_tailscale_auth_key") == "[REDACTED]"
         assert s.get("some_future_ts_setting") == "[REDACTED]"
         assert s.get("some_future_ts_setting") == "[REDACTED]"
+        assert s.get("manyfold_url") == "[REDACTED]"
+        assert s.get("manyfold_client_id") == "[REDACTED]"
+        assert s.get("manyfold_client_secret") == "[REDACTED]"
 
 
 
 
 class TestParseObicoEnabledPrinters:
 class TestParseObicoEnabledPrinters:

+ 2 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -125,7 +125,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   // Brand / technical names that ship verbatim everywhere.
   // Brand / technical names that ship verbatim everywhere.
-  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Bark|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Bark|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
   return false;
   return false;
 }
 }
 
 
@@ -227,6 +227,7 @@ const FR_COGNATES = [
   'Support',  // same word in French
   'Support',  // same word in French
   'Photos', '{{count}} photo', '{{count}} photos',  // file details photo strip (#3077) — same word in French
   'Photos', '{{count}} photo', '{{count}} photos',  // file details photo strip (#3077) — same word in French
   'Version 2',  // stream overlay artwork picker (#3177) — same word in French
   'Version 2',  // stream overlay artwork picker (#3177) — same word in French
+  'Page {{page}}',  // Manyfold model list pager (#1471) — "page" is the same word in French
 ];
 ];
 
 
 // Italian cognates.
 // Italian cognates.

+ 8 - 4
frontend/src/App.tsx

@@ -21,7 +21,7 @@ import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { GroupEditPage } from './pages/GroupEditPage';
 import { GroupEditPage } from './pages/GroupEditPage';
 import { PrinterLocationsPage } from './pages/PrinterLocationsPage';
 import { PrinterLocationsPage } from './pages/PrinterLocationsPage';
 import InventoryPage from './pages/InventoryPage';
 import InventoryPage from './pages/InventoryPage';
-import { MakerworldPage } from './pages/MakerworldPage';
+import { ModelSourcesPage } from './pages/ModelSourcesPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { LoginPage } from './pages/LoginPage';
 import { ConnectAuthorizePage } from './pages/ConnectAuthorizePage';
 import { ConnectAuthorizePage } from './pages/ConnectAuthorizePage';
@@ -112,7 +112,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
   return <>{children}</>;
   return <>{children}</>;
 }
 }
 
 
-function PermissionRoute({ permission, children }: { permission: string; children: React.ReactNode }) {
+function PermissionRoute({ permission, children }: { permission: string | string[]; children: React.ReactNode }) {
   // Permission-gated route: any user with the given permission can enter, not
   // Permission-gated route: any user with the given permission can enter, not
   // just admins. Individual components below this guard apply their own
   // just admins. Individual components below this guard apply their own
   // per-action permission checks. Used for pages where delegation is supported
   // per-action permission checks. Used for pages where delegation is supported
@@ -134,7 +134,9 @@ function PermissionRoute({ permission, children }: { permission: string; childre
     return <Navigate to="/login" replace state={{ from: location }} />;
     return <Navigate to="/login" replace state={{ from: location }} />;
   }
   }
 
 
-  if (!hasPermission(permission as Parameters<typeof hasPermission>[0])) {
+  // A list lets in anyone holding at least one of the permissions.
+  const required = (Array.isArray(permission) ? permission : [permission]) as Parameters<typeof hasPermission>[0][];
+  if (!required.some((p) => hasPermission(p))) {
     return <Navigate to="/" replace />;
     return <Navigate to="/" replace />;
   }
   }
 
 
@@ -225,7 +227,9 @@ function App() {
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="files/trash" element={<LibraryTrashPage />} />
                   <Route path="files/trash" element={<LibraryTrashPage />} />
-                  <Route path="makerworld" element={<PermissionRoute permission="makerworld:view"><MakerworldPage /></PermissionRoute>} />
+                  <Route path="model-sources" element={<PermissionRoute permission={['makerworld:view', 'manyfold:view']}><ModelSourcesPage /></PermissionRoute>} />
+                  {/* The page was MakerWorld-only until Manyfold joined it (#1471); old links and bookmarks still land on that tab. */}
+                  <Route path="makerworld" element={<Navigate to="/model-sources?tab=makerworld" replace />} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />

+ 15 - 4
frontend/src/__tests__/components/Layout.test.tsx

@@ -605,12 +605,13 @@ describe('Layout', () => {
 
 
     const findMakerWorldNavLink = () => {
     const findMakerWorldNavLink = () => {
       // Sidebar nav links use react-router's `to` prop, which renders as a
       // Sidebar nav links use react-router's `to` prop, which renders as a
-      // plain `<a href="/makerworld">`. Match on the href so the test isn't
-      // coupled to whatever locale string is rendered.
-      return document.querySelector('aside a[href="/makerworld"]');
+      // plain `<a href="/model-sources">`. Match on the href so the test isn't
+      // coupled to whatever locale string is rendered. The page was
+      // MakerWorld-only until Manyfold joined it as a second tab (#1471).
+      return document.querySelector('aside a[href="/model-sources"]');
     };
     };
 
 
-    it('hides the MakerWorld nav entry when the user lacks makerworld:view', async () => {
+    it('hides the Model Sources nav entry when the user has neither source permission', async () => {
       // Standard user without the MakerWorld permission. Every other
       // Standard user without the MakerWorld permission. Every other
       // permission they hold (library:read, etc.) is irrelevant here — the
       // permission they hold (library:read, etc.) is irrelevant here — the
       // gate is per-entry and the MakerWorld entry must not render.
       // gate is per-entry and the MakerWorld entry must not render.
@@ -643,6 +644,16 @@ describe('Layout', () => {
         expect(findMakerWorldNavLink()).toBeInTheDocument();
         expect(findMakerWorldNavLink()).toBeInTheDocument();
       });
       });
     });
     });
+
+    it('shows the Model Sources nav entry with manyfold:view alone (#1471)', async () => {
+      enableAuthWithUser(['library:read', 'manyfold:view']);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(findMakerWorldNavLink()).toBeInTheDocument();
+      });
+    });
   });
   });
 
 
   describe('Sidebar gate accepts granular read tiers (#1755)', () => {
   describe('Sidebar gate accepts granular read tiers (#1755)', () => {

+ 9 - 0
frontend/src/__tests__/components/LibraryFileDetailsModal.test.tsx

@@ -93,6 +93,15 @@ describe('LibraryFileDetailsModal', () => {
     expect(photo.src).toContain('/library/files/7/photos/abc123.jpg');
     expect(photo.src).toContain('/library/files/7/photos/abc123.jpg');
   });
   });
 
 
+  it('shows a source that is not a web address as text, not a dead link (#1471)', async () => {
+    server.use(
+      http.get('/api/v1/library/files/7', () => HttpResponse.json({ ...details, source_url: 'manyfold:cube01/f1' })),
+    );
+    render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+    expect(await screen.findByText('manyfold:cube01/f1')).toBeInTheDocument();
+    expect(screen.queryByRole('link', { name: /manyfold:cube01/ })).toBeNull();
+  });
+
   it('saves edited notes and link through updateLibraryFile', async () => {
   it('saves edited notes and link through updateLibraryFile', async () => {
     const user = userEvent.setup();
     const user = userEvent.setup();
     render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
     render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);

+ 367 - 0
frontend/src/__tests__/components/ManyfoldTab.test.tsx

@@ -0,0 +1,367 @@
+/**
+ * The Manyfold tab of Model Sources (#1471): connecting an install, browsing
+ * and searching its models, importing files, and slicing what was imported.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { ManyfoldTab } from '../../components/ManyfoldTab';
+import { setAuthToken } from '../../api/client';
+
+const model = {
+  id: 'cube01',
+  name: 'Calibration Cube',
+  caption: 'A 20 mm cube',
+  description: 'Prints in 10 minutes',
+  license: 'CC-BY-4.0',
+  tags: ['test', 'cube'],
+  url: 'http://manyfold.local/models/cube01',
+  has_preview: true,
+  files: [
+    { id: 'f1', name: 'Cube', mime: 'model/stl', importable: true, library_file: null },
+    { id: 'f2', name: 'Cube Plate', mime: 'model/3mf', importable: true, library_file: { id: 42, filename: 'cube plate.3mf', folder_id: 7 } },
+    { id: 'f3', name: 'Notes', mime: 'application/pdf', importable: false, library_file: null },
+    { id: 'f4', name: 'Cube Step', mime: 'model/step', importable: true, library_file: null },
+  ],
+};
+
+function connected(opts: { models?: Array<{ id: string; name: string }>; total?: number; hasNext?: boolean; useSlicerApi?: boolean } = {}) {
+  const models = opts.models ?? [
+    { id: 'cube01', name: 'Calibration Cube' },
+    { id: 'boat02', name: 'Benchy' },
+  ];
+  const listRequests: URLSearchParams[] = [];
+  server.use(
+    http.get('*/manyfold/status', () => HttpResponse.json({ configured: true, url: 'http://manyfold.local' })),
+    http.get('*/manyfold/models', ({ request }) => {
+      const params = new URL(request.url).searchParams;
+      listRequests.push(params);
+      const page = Number(params.get('page'));
+      return HttpResponse.json({
+        total: opts.total ?? models.length,
+        page,
+        has_next: opts.hasNext ?? false,
+        has_previous: page > 1,
+        models,
+      });
+    }),
+    http.get('*/manyfold/models/:id/preview', () => new HttpResponse(null, { status: 404 })),
+    http.get('*/manyfold/models/:id', ({ params }) =>
+      HttpResponse.json(
+        params.id === 'cube01'
+          ? model
+          : { ...model, id: params.id, name: models.find((m) => m.id === params.id)?.name ?? String(params.id) },
+      ),
+    ),
+    http.get('*/library/folders', () => HttpResponse.json([])),
+    http.get('*/settings/', () => HttpResponse.json({ use_slicer_api: opts.useSlicerApi ?? true, preferred_slicer: 'bambu_studio' })),
+  );
+  return { listRequests };
+}
+
+describe('ManyfoldTab', () => {
+  beforeEach(() => {
+    // jsdom has no object URLs; previews are blobs turned into one.
+    URL.createObjectURL = vi.fn(() => 'blob:preview');
+    URL.revokeObjectURL = vi.fn();
+  });
+  afterEach(() => {
+    vi.restoreAllMocks();
+    setAuthToken(null);
+  });
+
+  describe('connecting', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('*/manyfold/status', () => HttpResponse.json({ configured: false, url: '' })),
+        http.get('*/manyfold/config', () =>
+          HttpResponse.json({ url: '', client_id: '', has_client_secret: false, configured: false }),
+        ),
+      );
+    });
+
+    it('tests and saves the connection, then shows the models', async () => {
+      const user = userEvent.setup();
+      let saved: Record<string, unknown> | null = null;
+      let tested: Record<string, unknown> | null = null;
+      server.use(
+        http.post('*/manyfold/config/test', async ({ request }) => {
+          tested = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ model_count: 12 });
+        }),
+        http.put('*/manyfold/config', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          connected();
+          return HttpResponse.json({ url: 'http://manyfold.local', client_id: 'app', has_client_secret: true, configured: true });
+        }),
+      );
+      render(<ManyfoldTab />);
+
+      const save = await screen.findByRole('button', { name: 'Save' });
+      expect(save).toBeDisabled();
+      await user.type(screen.getByLabelText('Manyfold URL'), 'http://manyfold.local');
+      await user.type(screen.getByLabelText('Client ID'), 'app');
+      await user.type(screen.getByLabelText('Client secret'), 's3cret');
+
+      await user.click(screen.getByRole('button', { name: 'Test connection' }));
+      expect(await screen.findByRole('status')).toHaveTextContent('Models Bambuddy can see: 12');
+      expect(tested).toEqual({ url: 'http://manyfold.local', client_id: 'app', client_secret: 's3cret' });
+
+      await user.click(save);
+      await waitFor(() => expect(saved).toEqual({ url: 'http://manyfold.local', client_id: 'app', client_secret: 's3cret' }));
+      expect(await screen.findByText('Settings saved')).toBeInTheDocument();
+      expect(await screen.findByText('Benchy')).toBeInTheDocument();
+    });
+
+    it('shows its own text for a refused secret', async () => {
+      const user = userEvent.setup();
+      server.use(
+        http.post('*/manyfold/config/test', () =>
+          HttpResponse.json(
+            { detail: { code: 'manyfold_credentials', message: 'Manyfold did not accept the client ID or secret.' } },
+            { status: 502 },
+          ),
+        ),
+      );
+      render(<ManyfoldTab />);
+      await user.type(await screen.findByLabelText('Manyfold URL'), 'http://manyfold.local');
+      await user.type(screen.getByLabelText('Client ID'), 'app');
+      await user.type(screen.getByLabelText('Client secret'), 'wrong');
+      await user.click(screen.getByRole('button', { name: 'Test connection' }));
+      expect(await screen.findByRole('status')).toHaveTextContent('Manyfold did not accept the client ID or secret.');
+    });
+
+    it('keeps a stored secret when the field is left empty', async () => {
+      const user = userEvent.setup();
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('*/manyfold/config', () =>
+          HttpResponse.json({ url: 'http://manyfold.local', client_id: 'app', has_client_secret: true, configured: false }),
+        ),
+        http.put('*/manyfold/config', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ url: 'http://manyfold.local', client_id: 'app', has_client_secret: true, configured: true });
+        }),
+      );
+      render(<ManyfoldTab />);
+      const secret = await screen.findByPlaceholderText('Stored. Leave empty to keep it.');
+      expect(secret).toHaveValue('');
+      await waitFor(() => expect(screen.getByLabelText('Manyfold URL')).toHaveValue('http://manyfold.local'));
+      await user.click(screen.getByRole('button', { name: 'Save' }));
+      await waitFor(() => expect(saved).toEqual({ url: 'http://manyfold.local', client_id: 'app' }));
+    });
+  });
+
+  describe('browsing', () => {
+    it('lists models, searches, and pages', async () => {
+      const user = userEvent.setup();
+      const { listRequests } = connected({ total: 30, hasNext: true });
+      render(<ManyfoldTab />);
+
+      expect(await screen.findByText('Calibration Cube')).toBeInTheDocument();
+      expect(screen.getByText('Models: 30')).toBeInTheDocument();
+
+      await user.type(screen.getByRole('searchbox'), 'bench');
+      await user.click(screen.getByRole('button', { name: 'Search' }));
+      await waitFor(() => expect(listRequests.at(-1)?.get('q')).toBe('bench'));
+
+      await user.click(screen.getByRole('button', { name: /Next/ }));
+      await waitFor(() => expect(listRequests.at(-1)?.get('page')).toBe('2'));
+      expect(listRequests.at(-1)?.get('q')).toBe('bench');
+    });
+
+    it('shows a placeholder for a model without a preview, without an <img>', async () => {
+      connected();
+      render(<ManyfoldTab />);
+      await screen.findByText('Benchy');
+      // A failing <img> on a protected URL would make the app renew its media token.
+      await waitFor(() => expect(document.querySelectorAll('img')).toHaveLength(0));
+    });
+
+    it('shows a preview Manyfold has', async () => {
+      connected();
+      server.use(
+        http.get('*/manyfold/models/:id/preview', () =>
+          new HttpResponse(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'Content-Type': 'image/png' } }),
+        ),
+      );
+      render(<ManyfoldTab />);
+      await waitFor(() => expect(document.querySelectorAll('img[src="blob:preview"]').length).toBeGreaterThan(0));
+    });
+  });
+
+  describe('a model', () => {
+    it('imports a file and offers library actions for imported ones', async () => {
+      const user = userEvent.setup();
+      connected();
+      let imported: Record<string, unknown> | null = null;
+      server.use(
+        http.post('*/manyfold/import', async ({ request }) => {
+          imported = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ library_file_id: 43, filename: 'cube.stl', folder_id: 7, was_existing: false });
+        }),
+      );
+      render(<ManyfoldTab />);
+      await user.click(await screen.findByText('Calibration Cube'));
+
+      expect(await screen.findByText('A 20 mm cube')).toBeInTheDocument();
+      expect(screen.getByRole('link', { name: /Open in Manyfold/ })).toHaveAttribute('href', model.url);
+
+      const rows = screen.getAllByRole('listitem');
+      const stl = rows.find((row) => within(row).queryByText('Cube'))!;
+      const plate = rows.find((row) => within(row).queryByText('Cube Plate'))!;
+      const notes = rows.find((row) => within(row).queryByText('Notes'))!;
+
+      expect(within(plate).getByText('In library')).toBeInTheDocument();
+      expect(within(plate).getByRole('button', { name: /Slice/ })).toBeInTheDocument();
+      expect(within(notes).queryByRole('button')).toBeNull();
+      expect(within(notes).getByText("Bambuddy can't slice or print this type")).toBeInTheDocument();
+
+      await user.click(within(stl).getByRole('button', { name: /Import/ }));
+      await waitFor(() => expect(imported).toEqual({ model_id: 'cube01', file_id: 'f1', folder_id: null }));
+      expect(await screen.findByText('Imported cube.stl')).toBeInTheDocument();
+    });
+
+    it('hides importing from users without manyfold:import', async () => {
+      // settings:update shows the Connection button, which proves the user's
+      // permissions were loaded rather than everything being hidden.
+      connected();
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 2,
+            username: 'viewer',
+            role: 'user',
+            is_active: true,
+            is_admin: false,
+            groups: [],
+            permissions: ['manyfold:view', 'settings:update'],
+            created_at: '2026-01-01T00:00:00Z',
+          }),
+        ),
+      );
+      setAuthToken('test-token', 'session');
+      const user = userEvent.setup();
+      render(<ManyfoldTab />);
+      expect(await screen.findByRole('button', { name: /Connection/ })).toBeInTheDocument();
+      await user.click(await screen.findByText('Calibration Cube'));
+      await screen.findByText('A 20 mm cube');
+      expect(screen.queryByRole('button', { name: /^Import$/ })).toBeNull();
+      expect(screen.queryByText('Import to')).toBeNull();
+    });
+  });
+
+  describe('importing several files', () => {
+    /** Records each import, and how many ran at the same time. */
+    function recordImports(fail: string[] = []) {
+      const calls: Array<Record<string, unknown>> = [];
+      let running = 0;
+      let maxRunning = 0;
+      server.use(
+        http.post('*/manyfold/import', async ({ request }) => {
+          const body = (await request.json()) as Record<string, unknown>;
+          calls.push(body);
+          running += 1;
+          maxRunning = Math.max(maxRunning, running);
+          await new Promise((resolve) => setTimeout(resolve, 20));
+          running -= 1;
+          if (fail.includes(String(body.file_id))) {
+            return HttpResponse.json(
+              { detail: { code: 'manyfold_unreachable', message: 'down' } },
+              { status: 502 },
+            );
+          }
+          return HttpResponse.json({ library_file_id: 50, filename: `${body.file_id}.stl`, folder_id: 7, was_existing: false });
+        }),
+      );
+      return { calls, maxRunning: () => maxRunning };
+    }
+
+    it('imports only the ticked files', async () => {
+      const user = userEvent.setup();
+      connected();
+      const rec = recordImports();
+      render(<ManyfoldTab />);
+      await user.click(await screen.findByText('Calibration Cube'));
+      const importSelected = await screen.findByRole('button', { name: /Import selected \(0\)/ });
+      expect(importSelected).toBeDisabled();
+      // Imported and unprintable files can't be ticked.
+      expect(screen.queryByRole('checkbox', { name: 'Select Cube Plate' })).toBeNull();
+      expect(screen.queryByRole('checkbox', { name: 'Select Notes' })).toBeNull();
+
+      await user.click(screen.getByRole('checkbox', { name: 'Select Cube Step' }));
+      await user.click(screen.getByRole('button', { name: /Import selected \(1\)/ }));
+      await waitFor(() => expect(rec.calls).toEqual([{ model_id: 'cube01', file_id: 'f4', folder_id: null }]));
+      expect(await screen.findByText('Imported: 1 · already in library: 0 · failed: 0')).toBeInTheDocument();
+    });
+
+    it('Import all takes every importable file, one at a time, and carries on past a failure', async () => {
+      const user = userEvent.setup();
+      connected();
+      const rec = recordImports(['f1']);
+      render(<ManyfoldTab />);
+      await user.click(await screen.findByText('Calibration Cube'));
+      await user.click(await screen.findByRole('button', { name: 'Import all' }));
+
+      await waitFor(() => expect(rec.calls.map((c) => c.file_id)).toEqual(['f1', 'f4']));
+      expect(rec.maxRunning()).toBe(1);
+      expect(await screen.findByText('Imported: 1 · already in library: 0 · failed: 1')).toBeInTheDocument();
+      expect(
+        screen.getByText("Not imported: Cube. Bambuddy can't reach Manyfold. Check the URL and that Manyfold is running."),
+      ).toBeInTheDocument();
+    });
+
+    it('Select all ticks every importable file', async () => {
+      const user = userEvent.setup();
+      connected();
+      render(<ManyfoldTab />);
+      await user.click(await screen.findByText('Calibration Cube'));
+      await user.click(await screen.findByRole('checkbox', { name: 'Select all' }));
+      expect(screen.getByRole('button', { name: /Import selected \(2\)/ })).toBeEnabled();
+      await user.click(screen.getByRole('checkbox', { name: 'Select all' }));
+      expect(screen.getByRole('button', { name: /Import selected \(0\)/ })).toBeDisabled();
+    });
+
+    it('ticked models in the grid import all their files, and the ticks survive paging', async () => {
+      const user = userEvent.setup();
+      connected({ total: 30, hasNext: true });
+      const rec = recordImports();
+      render(<ManyfoldTab />);
+
+      await user.click(await screen.findByRole('checkbox', { name: 'Select Calibration Cube' }));
+      expect(screen.getByText('Models selected: 1')).toBeInTheDocument();
+      await user.click(screen.getByRole('button', { name: /Next/ }));
+      await waitFor(() => expect(screen.getByText('Page 2')).toBeInTheDocument());
+      expect(screen.getByText('Models selected: 1')).toBeInTheDocument();
+      await user.click(screen.getByRole('checkbox', { name: 'Select Benchy' }));
+      expect(screen.getByText('Models selected: 2')).toBeInTheDocument();
+
+      await user.click(screen.getByRole('button', { name: 'Import their files' }));
+      await waitFor(() => expect(rec.calls).toHaveLength(4));
+      expect(rec.calls.map((c) => `${c.model_id}/${c.file_id}`)).toEqual([
+        'cube01/f1',
+        'cube01/f4',
+        'boat02/f1',
+        'boat02/f4',
+      ]);
+      expect(rec.maxRunning()).toBe(1);
+      expect(await screen.findByText('Imported: 4 · already in library: 0 · failed: 0')).toBeInTheDocument();
+      await waitFor(() => expect(screen.queryByText(/Models selected/)).toBeNull());
+    });
+
+    it('Select page ticks every model shown', async () => {
+      const user = userEvent.setup();
+      connected();
+      render(<ManyfoldTab />);
+      await user.click(await screen.findByRole('button', { name: 'Select page' }));
+      expect(screen.getByText('Models selected: 2')).toBeInTheDocument();
+      await user.click(screen.getByRole('button', { name: 'Clear selection' }));
+      expect(screen.queryByText(/Models selected/)).toBeNull();
+    });
+  });
+});

+ 134 - 0
frontend/src/__tests__/pages/ModelSourcesPage.test.tsx

@@ -0,0 +1,134 @@
+/**
+ * Model Sources (#1471): one tab per source, each shown only to users with
+ * that source's permission, and Manyfold only once it is connected unless the
+ * user can connect it.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { ModelSourcesPage } from '../../pages/ModelSourcesPage';
+import { setAuthToken } from '../../api/client';
+
+function sources(opts: { manyfoldConfigured: boolean }) {
+  const served = { status: 0 };
+  server.use(
+    http.get('*/makerworld/status', () => HttpResponse.json({ has_cloud_token: true, can_download: true })),
+    http.get('*/makerworld/recent-imports', () => HttpResponse.json([])),
+    http.get('*/library/folders', () => HttpResponse.json([])),
+    http.get('*/settings/', () => HttpResponse.json({ preferred_slicer: 'bambu_studio' })),
+    http.get('*/manyfold/status', () => {
+      served.status += 1;
+      return HttpResponse.json({ configured: opts.manyfoldConfigured, url: opts.manyfoldConfigured ? 'http://mf' : '' });
+    }),
+    http.get('*/manyfold/config', () =>
+      HttpResponse.json({ url: '', client_id: '', has_client_secret: false, configured: false }),
+    ),
+    http.get('*/manyfold/models', () =>
+      HttpResponse.json({ total: 1, page: 1, has_next: false, has_previous: false, models: [{ id: 'm1', name: 'Benchy' }] }),
+    ),
+    http.get('*/manyfold/models/:id/preview', () => new HttpResponse(null, { status: 404 })),
+  );
+  return served;
+}
+
+function signedInWith(permissions: string[]) {
+  server.use(
+    http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({
+        id: 3,
+        username: 'member',
+        role: 'user',
+        is_active: true,
+        is_admin: false,
+        groups: [],
+        permissions,
+        created_at: '2026-01-01T00:00:00Z',
+      }),
+    ),
+  );
+  setAuthToken('test-token', 'session');
+}
+
+describe('ModelSourcesPage', () => {
+  beforeEach(() => {
+    URL.createObjectURL = vi.fn(() => 'blob:preview');
+    URL.revokeObjectURL = vi.fn();
+    window.history.replaceState({}, '', '/model-sources');
+  });
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  it('shows both sources and switches between them', async () => {
+    const user = userEvent.setup();
+    sources({ manyfoldConfigured: true });
+    render(<ModelSourcesPage />);
+
+    expect(screen.getByRole('heading', { name: 'Model Sources' })).toBeInTheDocument();
+    const manyfoldTab = await screen.findByRole('tab', { name: 'Manyfold' });
+    expect(screen.getByRole('tab', { name: 'MakerWorld' })).toHaveAttribute('aria-selected', 'true');
+    expect(screen.getByText('Import from MakerWorld')).toBeInTheDocument();
+
+    await user.click(manyfoldTab);
+    expect(await screen.findByText('Benchy')).toBeInTheDocument();
+    expect(manyfoldTab).toHaveAttribute('aria-selected', 'true');
+    expect(window.location.search).toBe('?tab=manyfold');
+  });
+
+  it('opens the tab named in the link', async () => {
+    sources({ manyfoldConfigured: true });
+    window.history.replaceState({}, '', '/model-sources?tab=manyfold');
+    render(<ModelSourcesPage />);
+    expect(await screen.findByText('Benchy')).toBeInTheDocument();
+  });
+
+  it('MakerWorld alone needs no tabs', async () => {
+    sources({ manyfoldConfigured: true });
+    signedInWith(['makerworld:view']);
+    render(<ModelSourcesPage />);
+    expect(await screen.findByText('Import from MakerWorld')).toBeInTheDocument();
+    expect(screen.queryByRole('tablist')).toBeNull();
+  });
+
+  it('a Manyfold-only user lands on Manyfold', async () => {
+    sources({ manyfoldConfigured: true });
+    signedInWith(['manyfold:view']);
+    render(<ModelSourcesPage />);
+    expect(await screen.findByText('Benchy')).toBeInTheDocument();
+    expect(screen.queryByText('Import from MakerWorld')).toBeNull();
+  });
+
+  it('hides an unconnected Manyfold from users who cannot connect it', async () => {
+    const served = sources({ manyfoldConfigured: false });
+    signedInWith(['makerworld:view', 'manyfold:view']);
+    render(<ModelSourcesPage />);
+    expect(await screen.findByText('Import from MakerWorld')).toBeInTheDocument();
+    // The answer must have arrived, or the tab is only missing because it hasn't yet.
+    await waitFor(() => expect(served.status).toBeGreaterThan(0));
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(screen.queryByRole('tab', { name: 'Manyfold' })).toBeNull();
+    expect(screen.queryByRole('tablist')).toBeNull();
+  });
+
+  it('shows an unconnected Manyfold to users who can connect it', async () => {
+    const user = userEvent.setup();
+    sources({ manyfoldConfigured: false });
+    signedInWith(['makerworld:view', 'manyfold:view', 'settings:update']);
+    render(<ModelSourcesPage />);
+    await user.click(await screen.findByRole('tab', { name: 'Manyfold' }));
+    expect(await screen.findByText('Manyfold connection')).toBeInTheDocument();
+  });
+
+  it('tells a Manyfold-only user that Manyfold is not connected yet, rather than showing an empty page', async () => {
+    sources({ manyfoldConfigured: false });
+    signedInWith(['manyfold:view']);
+    render(<ModelSourcesPage />);
+    expect(await screen.findByText('Manyfold is not connected')).toBeInTheDocument();
+    expect(screen.queryByRole('tablist')).toBeNull();
+  });
+});

+ 94 - 0
frontend/src/api/client.ts

@@ -1724,6 +1724,69 @@ export interface MakerworldRecentImport {
   created_at: string;
   created_at: string;
 }
 }
 
 
+// Manyfold integration (#1471): a self-hosted model library, browsed and
+// searched, with single files imported into the library.
+export interface ManyfoldConfig {
+  url: string;
+  client_id: string;
+  /** The secret itself is never returned. */
+  has_client_secret: boolean;
+  configured: boolean;
+}
+
+export interface ManyfoldConfigInput {
+  url: string;
+  client_id: string;
+  /** Empty or left out keeps the stored secret. */
+  client_secret?: string;
+}
+
+export interface ManyfoldStatus {
+  configured: boolean;
+  url: string;
+}
+
+export interface ManyfoldModelSummary {
+  id: string;
+  name: string;
+}
+
+export interface ManyfoldModelList {
+  total: number;
+  page: number;
+  has_next: boolean;
+  has_previous: boolean;
+  models: ManyfoldModelSummary[];
+}
+
+export interface ManyfoldFile {
+  id: string;
+  name: string;
+  mime: string;
+  importable: boolean;
+  /** Set while the file imported earlier is still in the library. */
+  library_file: { id: number; filename: string; folder_id: number | null } | null;
+}
+
+export interface ManyfoldModel {
+  id: string;
+  name: string;
+  caption: string | null;
+  description: string | null;
+  license: string | null;
+  tags: string[];
+  url: string;
+  has_preview: boolean;
+  files: ManyfoldFile[];
+}
+
+export interface ManyfoldImportResponse {
+  library_file_id: number;
+  filename: string;
+  folder_id: number | null;
+  was_existing: boolean;
+}
+
 export interface SlicerSetting {
 export interface SlicerSetting {
   setting_id: string;
   setting_id: string;
   name: string;
   name: string;
@@ -4307,6 +4370,7 @@ export type Permission =
   | 'github:backup' | 'github:restore'
   | 'github:backup' | 'github:restore'
   | 'cloud:auth' | 'orca_cloud:auth'
   | 'cloud:auth' | 'orca_cloud:auth'
   | 'makerworld:view' | 'makerworld:import'
   | 'makerworld:view' | 'makerworld:import'
+  | 'manyfold:view' | 'manyfold:import'
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'users:read' | 'users:read_slim' | 'users:create' | 'users:update' | 'users:delete'
   | 'users:read' | 'users:read_slim' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
@@ -6174,6 +6238,36 @@ export const api = {
         folder_id: folder_id ?? null,
         folder_id: folder_id ?? null,
       }),
       }),
     }),
     }),
+  // Manyfold (#1471).
+  getManyfoldConfig: () => request<ManyfoldConfig>('/manyfold/config'),
+  updateManyfoldConfig: (data: ManyfoldConfigInput) =>
+    request<ManyfoldConfig>('/manyfold/config', { method: 'PUT', body: JSON.stringify(data) }),
+  deleteManyfoldConfig: () => request<void>('/manyfold/config', { method: 'DELETE' }),
+  testManyfoldConfig: (data: ManyfoldConfigInput) =>
+    request<{ model_count: number }>('/manyfold/config/test', { method: 'POST', body: JSON.stringify(data) }),
+  getManyfoldStatus: () => request<ManyfoldStatus>('/manyfold/status'),
+  listManyfoldModels: (query: string, page: number) => {
+    const params = new URLSearchParams({ page: String(page) });
+    if (query) params.set('q', query);
+    return request<ManyfoldModelList>(`/manyfold/models?${params.toString()}`);
+  },
+  getManyfoldModel: (modelId: string) =>
+    request<ManyfoldModel>(`/manyfold/models/${encodeURIComponent(modelId)}`),
+  /** The model's preview, or null when it has none. Fetched rather than used
+   *  as an <img src>: a failing protected <img> makes the app renew its media
+   *  token, and models without a preview answer 404. */
+  getManyfoldPreview: async (modelId: string): Promise<Blob | null> => {
+    const headers: Record<string, string> = {};
+    if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
+    const response = await fetch(`${API_BASE}/manyfold/models/${encodeURIComponent(modelId)}/preview`, { headers });
+    if (!response.ok) return null;
+    return response.blob();
+  },
+  importManyfoldFile: (modelId: string, fileId: string, folderId: number | null) =>
+    request<ManyfoldImportResponse>('/manyfold/import', {
+      method: 'POST',
+      body: JSON.stringify({ model_id: modelId, file_id: fileId, folder_id: folderId }),
+    }),
   getCloudSettingDetail: (settingId: string) =>
   getCloudSettingDetail: (settingId: string) =>
     request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
     request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
   createCloudSetting: (data: SlicerSettingCreate) =>
   createCloudSetting: (data: SlicerSettingCreate) =>

+ 3 - 2
frontend/src/components/Layout.tsx

@@ -48,7 +48,8 @@ export const defaultNavItems: NavItem[] = [
   { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
   { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
-  { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
+  // Id kept from when the page was MakerWorld-only, so saved sidebar orders and hidden items still apply.
+  { id: 'makerworld', to: '/model-sources', icon: Globe, labelKey: 'nav.modelSources' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
   { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
   { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
   { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
@@ -351,7 +352,7 @@ export function Layout() {
       inventory: 'inventory:read',
       inventory: 'inventory:read',
       finance: 'cost_centers:read_own',
       finance: 'cost_centers:read_own',
       files: ['library:read', 'library:read_own', 'library:read_all'],
       files: ['library:read', 'library:read_own', 'library:read_all'],
-      makerworld: 'makerworld:view',
+      makerworld: ['makerworld:view', 'manyfold:view'],
       settings: 'settings:read',
       settings: 'settings:read',
       // The user-email-preferences API requires notifications:user_email, so
       // The user-email-preferences API requires notifications:user_email, so
       // gate the nav item on the same permission (both default groups —
       // gate the nav item on the same permission (both default groups —

+ 6 - 1
frontend/src/components/LibraryFileDetailsModal.tsx

@@ -137,9 +137,14 @@ export function LibraryFileDetailsModal({ file, canEdit, onClose }: LibraryFileD
   }
   }
   if (details?.sliced_for_model) facts.push({ label: t('fileManager.details.slicedFor'), value: details.sliced_for_model });
   if (details?.sliced_for_model) facts.push({ label: t('fileManager.details.slicedFor'), value: details.sliced_for_model });
   if (details?.source_url) {
   if (details?.source_url) {
+    // Only web addresses are links. A Manyfold import (#1471) records
+    // `manyfold:<model>/<file>`, which a browser can't open.
+    const isWebLink = /^https?:\/\//i.test(details.source_url);
     facts.push({
     facts.push({
       label: t('fileManager.details.source'),
       label: t('fileManager.details.source'),
-      value: (
+      value: !isWebLink ? (
+        <span className="truncate">{details.source_url}</span>
+      ) : (
         <a
         <a
           href={details.source_url}
           href={details.source_url}
           target="_blank"
           target="_blank"

+ 924 - 0
frontend/src/components/ManyfoldTab.tsx

@@ -0,0 +1,924 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
+import {
+  ArrowLeft,
+  Box,
+  Check,
+  ChevronLeft,
+  ChevronRight,
+  Cog,
+  Download,
+  ExternalLink,
+  FolderOpen,
+  Loader2,
+  Search,
+  Settings as SettingsIcon,
+} from 'lucide-react';
+
+import {
+  api,
+  ApiError,
+  type LibraryFolderTree,
+  type ManyfoldFile,
+  type ManyfoldModel,
+} from '../api/client';
+import { Button } from './Button';
+import { Card, CardContent, CardHeader } from './Card';
+import { ConfirmModal } from './ConfirmModal';
+import { SliceModal, type SliceSource } from './SliceModal';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { isSliceableLibraryFile } from '../utils/libraryFiles';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+
+const MANYFOLD_ERROR_CODES = new Set([
+  'manyfold_not_configured',
+  'manyfold_credentials',
+  'manyfold_scope',
+  'manyfold_rate_limited',
+  'manyfold_unreachable',
+  'manyfold_forbidden',
+  'manyfold_not_found',
+  'manyfold_too_large',
+  'manyfold_not_importable',
+  'manyfold_bad_url',
+  'manyfold_secret_required',
+  'manyfold_failed',
+]);
+
+/** The backend names each Manyfold failure with a code; show our own text for it. */
+function errorText(err: unknown, t: TFunction): string {
+  if (err instanceof ApiError && err.code && MANYFOLD_ERROR_CODES.has(err.code)) {
+    return t(`manyfold.errors.${err.code}`);
+  }
+  return err instanceof Error && err.message ? err.message : t('manyfold.errors.manyfold_failed');
+}
+
+function fileTypeLabel(mime: string): string {
+  const subtype = mime.split('/')[1] ?? '';
+  return (subtype.replace(/^x-/, '').split(/[+.;]/)[0] || '?').toUpperCase();
+}
+
+type FlatFolder = { folder: LibraryFolderTree; depth: number };
+function flattenFolderTree(tree: LibraryFolderTree, depth = 0, out: FlatFolder[] = []): FlatFolder[] {
+  out.push({ folder: tree, depth });
+  for (const child of tree.children ?? []) flattenFolderTree(child, depth + 1, out);
+  return out;
+}
+
+/** A model's preview from Manyfold, or a placeholder when it has none. */
+function ManyfoldPreview({ modelId, className }: { modelId: string; className?: string }) {
+  const { data: blob, isLoading } = useQuery({
+    queryKey: ['manyfold-preview', modelId],
+    queryFn: () => api.getManyfoldPreview(modelId),
+    staleTime: Infinity,
+    retry: false,
+  });
+  const [src, setSrc] = useState<string | null>(null);
+  useEffect(() => {
+    if (!blob) {
+      setSrc(null);
+      return;
+    }
+    const url = URL.createObjectURL(blob);
+    setSrc(url);
+    return () => URL.revokeObjectURL(url);
+  }, [blob]);
+
+  return (
+    <div className={`flex items-center justify-center bg-bambu-dark overflow-hidden ${className ?? ''}`}>
+      {src ? (
+        <img src={src} alt="" className="w-full h-full object-contain" />
+      ) : isLoading ? (
+        <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
+      ) : (
+        <Box className="w-10 h-10 text-bambu-gray/50" aria-hidden />
+      )}
+    </div>
+  );
+}
+
+/** URL, client ID and secret of the Manyfold OAuth application. */
+function ManyfoldConnectionCard({ onDone }: { onDone?: () => void }) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const configQuery = useQuery({ queryKey: ['manyfold-config'], queryFn: () => api.getManyfoldConfig() });
+  const [url, setUrl] = useState('');
+  const [clientId, setClientId] = useState('');
+  const [secret, setSecret] = useState('');
+  const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
+  const [confirmDisconnect, setConfirmDisconnect] = useState(false);
+
+  useEffect(() => {
+    if (configQuery.data) {
+      setUrl(configQuery.data.url);
+      setClientId(configQuery.data.client_id);
+    }
+  }, [configQuery.data]);
+
+  const hasStoredSecret = configQuery.data?.has_client_secret ?? false;
+  const input = { url: url.trim(), client_id: clientId.trim(), client_secret: secret.trim() || undefined };
+  const complete = !!input.url && !!input.client_id && (!!input.client_secret || hasStoredSecret);
+
+  const refresh = () => {
+    queryClient.invalidateQueries({ queryKey: ['manyfold-config'] });
+    queryClient.invalidateQueries({ queryKey: ['manyfold-status'] });
+    queryClient.invalidateQueries({ queryKey: ['manyfold-models'] });
+    queryClient.invalidateQueries({ queryKey: ['manyfold-model'] });
+    queryClient.invalidateQueries({ queryKey: ['manyfold-preview'] });
+  };
+
+  const testMutation = useMutation({
+    mutationFn: () => api.testManyfoldConfig(input),
+    onSuccess: (data) => setTestResult({ ok: true, text: t('manyfold.testOk', { count: data.model_count }) }),
+    onError: (err) => setTestResult({ ok: false, text: errorText(err, t) }),
+  });
+
+  const saveMutation = useMutation({
+    mutationFn: () => api.updateManyfoldConfig(input),
+    onSuccess: () => {
+      setSecret('');
+      refresh();
+      showToast(t('settings.toast.settingsSaved'), 'success');
+      onDone?.();
+    },
+    onError: (err) => showToast(errorText(err, t), 'error'),
+  });
+
+  const disconnectMutation = useMutation({
+    mutationFn: () => api.deleteManyfoldConfig(),
+    onSuccess: () => {
+      setConfirmDisconnect(false);
+      setUrl('');
+      setClientId('');
+      setSecret('');
+      setTestResult(null);
+      refresh();
+      showToast(t('manyfold.disconnected'), 'success');
+    },
+    onError: (err) => {
+      setConfirmDisconnect(false);
+      showToast(errorText(err, t), 'error');
+    },
+  });
+
+  const fieldClass =
+    'w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none';
+
+  return (
+    <Card>
+      <CardHeader>
+        <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+          <SettingsIcon className="w-5 h-5 text-bambu-green" />
+          {t('manyfold.connectionTitle')}
+        </h2>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <p className="text-sm text-bambu-gray">{t('manyfold.connectionHelp')}</p>
+        <form
+          className="space-y-3"
+          onSubmit={(e) => {
+            e.preventDefault();
+            if (complete) saveMutation.mutate();
+          }}
+        >
+          <div>
+            <label htmlFor="manyfold-url" className="block text-sm text-bambu-gray mb-1">
+              {t('manyfold.url')}
+            </label>
+            <input
+              id="manyfold-url"
+              type="url"
+              value={url}
+              onChange={(e) => {
+                setUrl(e.target.value);
+                setTestResult(null);
+              }}
+              placeholder={t('manyfold.urlPlaceholder')}
+              className={fieldClass}
+              autoComplete="off"
+            />
+          </div>
+          <div>
+            <label htmlFor="manyfold-client-id" className="block text-sm text-bambu-gray mb-1">
+              {t('manyfold.clientId')}
+            </label>
+            <input
+              id="manyfold-client-id"
+              type="text"
+              value={clientId}
+              onChange={(e) => {
+                setClientId(e.target.value);
+                setTestResult(null);
+              }}
+              className={fieldClass}
+              autoComplete="off"
+            />
+          </div>
+          <div>
+            <label htmlFor="manyfold-client-secret" className="block text-sm text-bambu-gray mb-1">
+              {t('manyfold.clientSecret')}
+            </label>
+            <input
+              id="manyfold-client-secret"
+              type="password"
+              value={secret}
+              onChange={(e) => {
+                setSecret(e.target.value);
+                setTestResult(null);
+              }}
+              placeholder={hasStoredSecret ? t('manyfold.clientSecretStored') : ''}
+              className={fieldClass}
+              autoComplete="new-password"
+            />
+          </div>
+          {testResult && (
+            <p
+              role="status"
+              className={`text-sm ${testResult.ok ? 'text-bambu-green' : 'text-red-400'}`}
+            >
+              {testResult.text}
+            </p>
+          )}
+          <div className="flex flex-wrap gap-2">
+            <Button type="submit" disabled={!complete || saveMutation.isPending}>
+              {saveMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
+              {t('common.save')}
+            </Button>
+            <Button
+              type="button"
+              variant="secondary"
+              disabled={!complete || testMutation.isPending}
+              onClick={() => testMutation.mutate()}
+            >
+              {testMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
+              {t('manyfold.test')}
+            </Button>
+            {configQuery.data?.configured && (
+              <Button type="button" variant="danger" onClick={() => setConfirmDisconnect(true)}>
+                {t('manyfold.disconnect')}
+              </Button>
+            )}
+            {onDone && configQuery.data?.configured && (
+              <Button type="button" variant="ghost" onClick={onDone}>
+                {t('common.cancel')}
+              </Button>
+            )}
+          </div>
+        </form>
+      </CardContent>
+      {confirmDisconnect && (
+        <ConfirmModal
+          title={t('manyfold.disconnectTitle')}
+          message={t('manyfold.disconnectBody')}
+          confirmText={t('manyfold.disconnect')}
+          variant="danger"
+          isLoading={disconnectMutation.isPending}
+          onCancel={() => setConfirmDisconnect(false)}
+          onConfirm={() => disconnectMutation.mutate()}
+        />
+      )}
+    </Card>
+  );
+}
+
+/** The library folder imports land in; empty means the "Manyfold" folder. */
+function ImportFolderSelect({
+  value,
+  onChange,
+  disabled,
+}: {
+  value: number | null;
+  onChange: (folderId: number | null) => void;
+  disabled?: boolean;
+}) {
+  const { t } = useTranslation();
+  const foldersQuery = useQuery({ queryKey: ['library-folders'], queryFn: () => api.getLibraryFolders() });
+  const options = (foldersQuery.data ?? [])
+    .filter((f) => !(f.is_external && f.external_readonly))
+    .flatMap((f) => flattenFolderTree(f));
+  return (
+    <label className="flex items-center gap-2 text-sm text-bambu-gray">
+      {t('manyfold.importTo')}
+      <select
+        value={value ?? ''}
+        disabled={disabled}
+        onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
+        className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+      >
+        <option value="">{t('manyfold.folderAuto')}</option>
+        {options.map(({ folder, depth }) => (
+          <option key={folder.id} value={folder.id}>
+            {`${'— '.repeat(depth)}${folder.name}`}
+          </option>
+        ))}
+      </select>
+    </label>
+  );
+}
+
+type BulkItem = { modelId: string; fileId: string; name: string };
+
+/** The first few names, then how many more. */
+function listNames(names: string[]): string {
+  return names.length > 3 ? `${names.slice(0, 3).join(', ')} +${names.length - 3}` : names.join(', ');
+}
+
+/**
+ * Imports several files one after another, not in parallel, to go easy on
+ * Manyfold and the disk. A file that fails doesn't stop the rest; one summary
+ * at the end says what happened.
+ */
+function useManyfoldBulkImport() {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
+
+  const run = async (items: BulkItem[], folderId: number | null) => {
+    if (items.length === 0) {
+      showToast(t('manyfold.bulkNothing'), 'info');
+      return;
+    }
+    let imported = 0;
+    let existing = 0;
+    const failed: string[] = [];
+    let firstError: unknown = null;
+    try {
+      for (let i = 0; i < items.length; i += 1) {
+        setProgress({ current: i + 1, total: items.length });
+        try {
+          const result = await api.importManyfoldFile(items[i].modelId, items[i].fileId, folderId);
+          if (result.was_existing) existing += 1;
+          else imported += 1;
+        } catch (err) {
+          failed.push(items[i].name);
+          firstError ??= err;
+        }
+      }
+    } finally {
+      setProgress(null);
+      queryClient.invalidateQueries({ queryKey: ['manyfold-model'] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+    }
+    showToast(
+      t('manyfold.bulkDone', { imported, existing, failed: failed.length }),
+      failed.length ? 'warning' : 'success',
+    );
+    if (failed.length) {
+      showToast(t('manyfold.bulkFailed', { names: listNames(failed), reason: errorText(firstError, t) }), 'error');
+    }
+  };
+
+  return { run, progress, busy: progress !== null };
+}
+
+/** One model: its details and files, each importable into the library. */
+function ManyfoldModelView({
+  modelId,
+  onBack,
+  onSlice,
+}: {
+  modelId: string;
+  onBack: () => void;
+  onSlice: (libraryFileId: number, filename: string) => void;
+}) {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const navigate = useNavigate();
+  const canImport = hasPermission('manyfold:import');
+  const [folderId, setFolderId] = useState<number | null>(null);
+  const [importingFileId, setImportingFileId] = useState<string | null>(null);
+  const [checked, setChecked] = useState<Set<string>>(new Set());
+  const bulk = useManyfoldBulkImport();
+
+  const modelQuery = useQuery({
+    queryKey: ['manyfold-model', modelId],
+    queryFn: () => api.getManyfoldModel(modelId),
+  });
+  const settingsQuery = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings() });
+  const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
+  const desktopSlicer: SlicerType = resolveDesktopSlicer(
+    settingsQuery.data?.open_in_slicer,
+    settingsQuery.data?.preferred_slicer,
+  );
+
+  const importMutation = useMutation({
+    mutationFn: (file: ManyfoldFile) => api.importManyfoldFile(modelId, file.id, folderId),
+    onMutate: (file) => setImportingFileId(file.id),
+    onSettled: () => setImportingFileId(null),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['manyfold-model', modelId] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      showToast(
+        data.was_existing ? t('manyfold.alreadyInLibrary') : t('manyfold.imported', { filename: data.filename }),
+        'success',
+      );
+    },
+    onError: (err) => showToast(errorText(err, t), 'error'),
+  });
+
+  if (modelQuery.isLoading) {
+    return (
+      <div className="flex justify-center py-12">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+  if (modelQuery.isError || !modelQuery.data) {
+    return (
+      <div className="space-y-4">
+        <Button variant="ghost" size="sm" onClick={onBack}>
+          <ArrowLeft className="w-4 h-4" />
+          {t('manyfold.back')}
+        </Button>
+        <p className="text-red-400 text-sm">{errorText(modelQuery.error, t)}</p>
+      </div>
+    );
+  }
+
+  const model: ManyfoldModel = modelQuery.data;
+  // Files that can still be imported: printable and not in the library yet.
+  const candidates = model.files.filter((f) => f.importable && !f.library_file);
+  const selected = candidates.filter((f) => checked.has(f.id));
+  const allSelected = candidates.length > 0 && selected.length === candidates.length;
+  const busy = bulk.busy || importMutation.isPending;
+  const importFiles = async (files: ManyfoldFile[]) => {
+    await bulk.run(
+      files.map((f) => ({ modelId: model.id, fileId: f.id, name: f.name })),
+      folderId,
+    );
+    setChecked(new Set());
+  };
+
+  return (
+    <div className="space-y-4">
+      <Button variant="ghost" size="sm" onClick={onBack}>
+        <ArrowLeft className="w-4 h-4" />
+        {t('manyfold.back')}
+      </Button>
+      <Card>
+        <CardContent>
+          <div className="flex flex-col md:flex-row gap-6 py-2">
+            <ManyfoldPreview modelId={model.id} className="w-full md:w-72 aspect-square rounded-lg shrink-0" />
+            <div className="min-w-0 flex-1 space-y-3">
+              <div>
+                <h2 className="text-xl font-semibold text-white break-words">{model.name}</h2>
+                {model.caption && <p className="text-bambu-gray mt-1">{model.caption}</p>}
+              </div>
+              {model.description && (
+                <p className="text-sm text-bambu-gray whitespace-pre-line break-words">{model.description}</p>
+              )}
+              {model.tags.length > 0 && (
+                <div className="flex flex-wrap gap-1.5">
+                  {model.tags.map((tag) => (
+                    <span key={tag} className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-xs text-white">
+                      {tag}
+                    </span>
+                  ))}
+                </div>
+              )}
+              <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
+                {model.license && (
+                  <span className="text-bambu-gray">
+                    {t('manyfold.license')}: <span className="text-white">{model.license}</span>
+                  </span>
+                )}
+                <a
+                  href={model.url}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="inline-flex items-center gap-1 text-bambu-green hover:underline"
+                >
+                  <ExternalLink className="w-3.5 h-3.5" />
+                  {t('manyfold.openInManyfold')}
+                </a>
+              </div>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <div className="flex flex-wrap items-center justify-between gap-3">
+            <h3 className="text-lg font-semibold text-white">{t('manyfold.files')}</h3>
+            {canImport && <ImportFolderSelect value={folderId} onChange={setFolderId} disabled={busy} />}
+          </div>
+          {canImport && candidates.length > 0 && (
+            <div className="flex flex-wrap items-center gap-3 mt-3">
+              <label className="flex items-center gap-2 text-sm text-white">
+                <input
+                  type="checkbox"
+                  checked={allSelected}
+                  ref={(el) => {
+                    if (el) el.indeterminate = selected.length > 0 && !allSelected;
+                  }}
+                  disabled={busy}
+                  onChange={() => setChecked(allSelected ? new Set() : new Set(candidates.map((f) => f.id)))}
+                  className="accent-bambu-green"
+                />
+                {t('manyfold.selectAll')}
+              </label>
+              {bulk.progress ? (
+                <span className="inline-flex items-center gap-2 text-sm text-bambu-gray">
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {t('manyfold.importProgress', bulk.progress)}
+                </span>
+              ) : (
+                <>
+                  <Button
+                    variant="primary"
+                    size="sm"
+                    disabled={busy || selected.length === 0}
+                    onClick={() => importFiles(selected)}
+                  >
+                    <Download className="w-3.5 h-3.5" />
+                    {t('manyfold.importSelected', { count: selected.length })}
+                  </Button>
+                  <Button variant="secondary" size="sm" disabled={busy} onClick={() => importFiles(candidates)}>
+                    {t('manyfold.importAll')}
+                  </Button>
+                </>
+              )}
+            </div>
+          )}
+        </CardHeader>
+        <CardContent>
+          {model.files.length === 0 ? (
+            <p className="text-sm text-bambu-gray">{t('manyfold.noFiles')}</p>
+          ) : (
+            <ul className="divide-y divide-bambu-dark-tertiary">
+              {model.files.map((file) => {
+                const imported = file.library_file;
+                const sliceable =
+                  imported !== null && isSliceableLibraryFile({ filename: imported.filename }, useSlicerApi, desktopSlicer);
+                return (
+                  <li key={file.id} className="flex flex-wrap items-center gap-3 py-2">
+                    {canImport && candidates.length > 0 && (
+                      <span className="w-4 shrink-0 flex">
+                        {file.importable && !imported && (
+                          <input
+                            type="checkbox"
+                            aria-label={t('manyfold.selectFile', { name: file.name })}
+                            checked={checked.has(file.id)}
+                            disabled={busy}
+                            onChange={() =>
+                              setChecked((prev) => {
+                                const next = new Set(prev);
+                                if (next.has(file.id)) next.delete(file.id);
+                                else next.add(file.id);
+                                return next;
+                              })
+                            }
+                            className="accent-bambu-green"
+                          />
+                        )}
+                      </span>
+                    )}
+                    <span className="px-1.5 py-0.5 rounded bg-bambu-dark-tertiary text-[11px] font-mono text-bambu-gray shrink-0">
+                      {fileTypeLabel(file.mime)}
+                    </span>
+                    <span className="text-sm text-white min-w-0 flex-1 break-words">{file.name}</span>
+                    <div className="flex flex-wrap items-center gap-2">
+                      {!file.importable ? (
+                        <span className="text-xs text-bambu-gray">{t('manyfold.notImportable')}</span>
+                      ) : imported ? (
+                        <>
+                          <span className="inline-flex items-center gap-1 text-xs text-bambu-green">
+                            <Check className="w-3.5 h-3.5" />
+                            {t('manyfold.inLibrary')}
+                          </span>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => navigate(imported.folder_id ? `/files?folder=${imported.folder_id}` : '/files')}
+                          >
+                            <FolderOpen className="w-3.5 h-3.5" />
+                            {t('manyfold.showInLibrary')}
+                          </Button>
+                          {sliceable && (
+                            <Button variant="ghost" size="sm" onClick={() => onSlice(imported.id, imported.filename)}>
+                              <Cog className="w-3.5 h-3.5" />
+                              {t('slice.action', 'Slice')}
+                            </Button>
+                          )}
+                        </>
+                      ) : canImport ? (
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          disabled={busy}
+                          onClick={() => importMutation.mutate(file)}
+                        >
+                          {importingFileId === file.id ? (
+                            <Loader2 className="w-3.5 h-3.5 animate-spin" />
+                          ) : (
+                            <Download className="w-3.5 h-3.5" />
+                          )}
+                          {importingFileId === file.id ? t('manyfold.importing') : t('manyfold.import')}
+                        </Button>
+                      ) : null}
+                    </div>
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+/** The Manyfold tab of the Model Sources page (#1471). */
+export function ManyfoldTab() {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const canConfigure = hasPermission('settings:update');
+  const [editingConnection, setEditingConnection] = useState(false);
+  const [queryInput, setQueryInput] = useState('');
+  const [query, setQuery] = useState('');
+  const [page, setPage] = useState(1);
+  const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
+  const [sliceSource, setSliceSource] = useState<SliceSource | null>(null);
+  // Models ticked in the grid, kept across pages and searches (id -> name).
+  const [picked, setPicked] = useState<Map<string, string>>(new Map());
+  const [gridFolderId, setGridFolderId] = useState<number | null>(null);
+  const [collecting, setCollecting] = useState<{ current: number; total: number } | null>(null);
+  const canImport = hasPermission('manyfold:import');
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const gridBulk = useManyfoldBulkImport();
+  const gridBusy = collecting !== null || gridBulk.busy;
+  const gridProgress = collecting
+    ? t('manyfold.collectProgress', collecting)
+    : gridBulk.progress
+      ? t('manyfold.importProgress', gridBulk.progress)
+      : null;
+
+  const togglePicked = (id: string, name: string) =>
+    setPicked((prev) => {
+      const next = new Map(prev);
+      if (next.has(id)) next.delete(id);
+      else next.set(id, name);
+      return next;
+    });
+
+  // Every printable file of the ticked models that isn't in the library yet.
+  // The models are read one after another first, then their files imported.
+  const importPicked = async () => {
+    const models = [...picked.keys()];
+    const items: BulkItem[] = [];
+    const unreadable: string[] = [];
+    try {
+      for (let i = 0; i < models.length; i += 1) {
+        setCollecting({ current: i + 1, total: models.length });
+        try {
+          const model = await queryClient.fetchQuery({
+            queryKey: ['manyfold-model', models[i]],
+            queryFn: () => api.getManyfoldModel(models[i]),
+          });
+          for (const f of model.files) {
+            if (f.importable && !f.library_file) items.push({ modelId: model.id, fileId: f.id, name: `${model.name}: ${f.name}` });
+          }
+        } catch {
+          unreadable.push(picked.get(models[i]) ?? models[i]);
+        }
+      }
+    } finally {
+      setCollecting(null);
+    }
+    if (unreadable.length) showToast(t('manyfold.modelsUnreadable', { names: listNames(unreadable) }), 'error');
+    await gridBulk.run(items, gridFolderId);
+    setPicked(new Map());
+  };
+
+  const settingsQuery = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings() });
+  const statusQuery = useQuery({ queryKey: ['manyfold-status'], queryFn: () => api.getManyfoldStatus() });
+  const configured = statusQuery.data?.configured ?? false;
+  const modelsQuery = useQuery({
+    queryKey: ['manyfold-models', query, page],
+    queryFn: () => api.listManyfoldModels(query, page),
+    enabled: configured,
+    placeholderData: (previous) => previous,
+  });
+
+  // Slicing follows the same preference the MakerWorld tab and the File
+  // Manager use: the in-app slicer when Use Slicer API is on, otherwise a
+  // handoff to the desktop slicer.
+  const handleSlice = async (libraryFileId: number, filename: string) => {
+    if (settingsQuery.data?.use_slicer_api) {
+      setSliceSource({ kind: 'libraryFile', id: libraryFileId, filename });
+      return;
+    }
+    const slicer = resolveDesktopSlicer(settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer);
+    try {
+      const { token } = await api.createLibrarySlicerToken(libraryFileId);
+      openInSlicer(`${window.location.origin}${api.getLibrarySlicerDownloadUrl(libraryFileId, token, filename)}`, slicer);
+    } catch {
+      // Auth disabled: the plain download URL is public then.
+      openInSlicer(`${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId)}`, slicer);
+    }
+  };
+
+  if (statusQuery.isLoading) {
+    return (
+      <div className="flex justify-center py-12">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  if (!configured || editingConnection) {
+    return (
+      <div className="space-y-6">
+        <p className="text-bambu-gray">{t('manyfold.description')}</p>
+        {canConfigure ? (
+          <ManyfoldConnectionCard onDone={configured ? () => setEditingConnection(false) : undefined} />
+        ) : (
+          <Card>
+            <CardContent>
+              <div className="py-2">
+                <p className="font-medium text-white">{t('manyfold.notConnectedTitle')}</p>
+                <p className="text-sm text-bambu-gray mt-1">{t('manyfold.notConnectedBody')}</p>
+              </div>
+            </CardContent>
+          </Card>
+        )}
+      </div>
+    );
+  }
+
+  const listing = modelsQuery.data;
+
+  return (
+    <div className="space-y-6">
+      {selectedModelId ? (
+        <ManyfoldModelView
+          modelId={selectedModelId}
+          onBack={() => setSelectedModelId(null)}
+          onSlice={handleSlice}
+        />
+      ) : (
+        <>
+          <div className="flex flex-wrap items-start justify-between gap-3">
+            <p className="text-bambu-gray flex-1 min-w-[16rem]">{t('manyfold.description')}</p>
+            {canConfigure && (
+              <Button variant="ghost" size="sm" onClick={() => setEditingConnection(true)}>
+                <SettingsIcon className="w-4 h-4" />
+                {t('manyfold.editConnection')}
+              </Button>
+            )}
+          </div>
+          <form
+            className="flex gap-2"
+            role="search"
+            onSubmit={(e) => {
+              e.preventDefault();
+              setQuery(queryInput.trim());
+              setPage(1);
+            }}
+          >
+            <div className="relative flex-1">
+              <Search className="w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2" />
+              <input
+                type="search"
+                value={queryInput}
+                onChange={(e) => setQueryInput(e.target.value)}
+                placeholder={t('manyfold.searchPlaceholder')}
+                aria-label={t('manyfold.searchPlaceholder')}
+                className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+              />
+            </div>
+            <Button type="submit">{t('common.search')}</Button>
+          </form>
+
+          {modelsQuery.isError ? (
+            <p className="text-sm text-red-400">{errorText(modelsQuery.error, t)}</p>
+          ) : !listing ? (
+            <div className="flex justify-center py-12">
+              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+            </div>
+          ) : listing.models.length === 0 ? (
+            <p className="text-sm text-bambu-gray">{t('manyfold.noModels')}</p>
+          ) : (
+            <>
+              <div className="flex flex-wrap items-center gap-3">
+                <p className="text-sm text-bambu-gray">{t('manyfold.modelCount', { count: listing.total })}</p>
+                {canImport && (
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    disabled={gridBusy}
+                    onClick={() =>
+                      setPicked((prev) => {
+                        const next = new Map(prev);
+                        for (const m of listing.models) next.set(m.id, m.name);
+                        return next;
+                      })
+                    }
+                  >
+                    {t('manyfold.selectPage')}
+                  </Button>
+                )}
+              </div>
+              {canImport && picked.size > 0 && (
+                <div className="sticky top-0 z-10 flex flex-wrap items-center gap-3 rounded-lg border border-bambu-green/40 bg-bambu-dark-secondary px-4 py-3">
+                  <span className="text-sm text-white">{t('manyfold.modelsSelected', { count: picked.size })}</span>
+                  <ImportFolderSelect value={gridFolderId} onChange={setGridFolderId} disabled={gridBusy} />
+                  {gridProgress ? (
+                    <span className="inline-flex items-center gap-2 text-sm text-bambu-gray">
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      {gridProgress}
+                    </span>
+                  ) : (
+                    <>
+                      <Button variant="primary" size="sm" onClick={importPicked}>
+                        <Download className="w-3.5 h-3.5" />
+                        {t('manyfold.importModels')}
+                      </Button>
+                      <Button variant="ghost" size="sm" onClick={() => setPicked(new Map())}>
+                        {t('manyfold.clearSelection')}
+                      </Button>
+                    </>
+                  )}
+                </div>
+              )}
+              <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
+                {listing.models.map((model) => {
+                  const isPicked = picked.has(model.id);
+                  return (
+                    <div
+                      key={model.id}
+                      className={`relative rounded-lg border bg-bambu-dark-secondary overflow-hidden transition-colors ${
+                        isPicked ? 'border-bambu-green ring-1 ring-bambu-green' : 'border-bambu-dark-tertiary hover:border-bambu-green'
+                      }`}
+                    >
+                      {/* Locked while the ticked models import, so a file can't be imported twice at once from the model view. */}
+                      <button
+                        type="button"
+                        onClick={() => setSelectedModelId(model.id)}
+                        disabled={gridBusy}
+                        className="block w-full text-left disabled:cursor-wait"
+                      >
+                        <ManyfoldPreview modelId={model.id} className="aspect-square" />
+                        <span className="block px-3 py-2 text-sm text-white truncate" title={model.name}>
+                          {model.name}
+                        </span>
+                      </button>
+                      {canImport && (
+                        <label className="absolute top-2 left-2 flex items-center justify-center w-7 h-7 rounded bg-black/60 cursor-pointer">
+                          <input
+                            type="checkbox"
+                            aria-label={t('manyfold.selectModel', { name: model.name })}
+                            checked={isPicked}
+                            disabled={gridBusy}
+                            onChange={() => togglePicked(model.id, model.name)}
+                            className="accent-bambu-green w-4 h-4"
+                          />
+                        </label>
+                      )}
+                    </div>
+                  );
+                })}
+              </div>
+              {(listing.has_previous || listing.has_next) && (
+                <div className="flex items-center justify-center gap-3">
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    disabled={!listing.has_previous || modelsQuery.isFetching}
+                    onClick={() => setPage((p) => Math.max(1, p - 1))}
+                  >
+                    <ChevronLeft className="w-4 h-4" />
+                    {t('manyfold.previous')}
+                  </Button>
+                  <span className="text-sm text-bambu-gray">{t('manyfold.page', { page: listing.page })}</span>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    disabled={!listing.has_next || modelsQuery.isFetching}
+                    onClick={() => setPage((p) => p + 1)}
+                  >
+                    {t('manyfold.next')}
+                    <ChevronRight className="w-4 h-4" />
+                  </Button>
+                </div>
+              )}
+            </>
+          )}
+        </>
+      )}
+      {sliceSource && <SliceModal source={sliceSource} onClose={() => setSliceSource(null)} />}
+    </div>
+  );
+}

+ 74 - 0
frontend/src/i18n/locales/de.ts

@@ -12,6 +12,7 @@ export default {
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'Dateimanager',
     files: 'Dateimanager',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Modellquellen',
     notifications: 'Benachrichtigungen',
     notifications: 'Benachrichtigungen',
     settings: 'Einstellungen',
     settings: 'Einstellungen',
     system: 'System',
     system: 'System',
@@ -7615,6 +7616,79 @@ export default {
     noHistory: 'Noch keine Erkennungen.',
     noHistory: 'Noch keine Erkennungen.',
   },
   },
 
 
+  modelSources: {
+    title: 'Modellquellen',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Durchsuche deine Manyfold-Bibliothek und importiere ihre Dateien, um sie zu slicen und zu drucken.',
+    notConnectedTitle: 'Manyfold ist nicht verbunden',
+    notConnectedBody: 'Bitte einen Administrator, deine Manyfold-Bibliothek zu verbinden.',
+    connectionTitle: 'Manyfold-Verbindung',
+    connectionHelp: 'Öffne in Manyfold Einstellungen → API und lege eine Anwendung mit den Berechtigungen "public" und "read" an. Trage hier ihre Client-ID und ihr Secret ein. Bambuddy sieht die Modelle, die der Besitzer der Anwendung sehen kann.',
+    url: 'Manyfold-URL',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'Client-ID',
+    clientSecret: 'Client-Secret',
+    clientSecretStored: 'Gespeichert. Leer lassen, um es zu behalten.',
+    test: 'Verbindung testen',
+    testOk: 'Verbunden. Für Bambuddy sichtbare Modelle: {{count}}',
+    disconnect: 'Trennen',
+    disconnectTitle: 'Manyfold trennen?',
+    disconnectBody: 'Bambuddy vergisst URL, Client-ID und Secret von Manyfold. Importierte Dateien bleiben in deiner Bibliothek.',
+    disconnected: 'Manyfold getrennt',
+    editConnection: 'Verbindung',
+    searchPlaceholder: 'Modelle suchen',
+    modelCount: 'Modelle: {{count}}',
+    noModels: 'Keine Modelle gefunden.',
+    previous: 'Zurück',
+    next: 'Weiter',
+    page: 'Seite {{page}}',
+    back: 'Zurück zu den Modellen',
+    openInManyfold: 'In Manyfold öffnen',
+    license: 'Lizenz',
+    files: 'Dateien',
+    noFiles: 'Dieses Modell hat keine Dateien.',
+    importTo: 'Importieren nach',
+    folderAuto: 'Ordner "Manyfold"',
+    import: 'Importieren',
+    importing: 'Wird importiert…',
+    imported: '{{filename}} importiert',
+    alreadyInLibrary: 'Bereits in deiner Bibliothek',
+    inLibrary: 'In der Bibliothek',
+    showInLibrary: 'In der Bibliothek zeigen',
+    notImportable: 'Bambuddy kann diesen Dateityp weder slicen noch drucken',
+    selectAll: 'Alle auswählen',
+    selectFile: '{{name}} auswählen',
+    importSelected: 'Auswahl importieren ({{count}})',
+    importAll: 'Alle importieren',
+    importProgress: 'Importiere {{current}}/{{total}}',
+    bulkDone: 'Importiert: {{imported}} · bereits in der Bibliothek: {{existing}} · fehlgeschlagen: {{failed}}',
+    bulkFailed: 'Nicht importiert: {{names}}. {{reason}}',
+    bulkNothing: 'Nichts zu importieren: Jede druckbare Datei ist bereits in deiner Bibliothek.',
+    selectModel: '{{name}} auswählen',
+    selectPage: 'Seite auswählen',
+    modelsSelected: 'Ausgewählte Modelle: {{count}}',
+    importModels: 'Ihre Dateien importieren',
+    clearSelection: 'Auswahl aufheben',
+    collectProgress: 'Lese Modelle {{current}}/{{total}}',
+    modelsUnreadable: 'Nicht lesbar: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold ist nicht verbunden.',
+      manyfold_credentials: 'Manyfold hat Client-ID oder Secret nicht akzeptiert.',
+      manyfold_scope: 'Die Manyfold-Anwendung braucht die Berechtigung "read". Bearbeite sie in Manyfold unter Einstellungen → API.',
+      manyfold_rate_limited: 'Manyfold begrenzt die Anmeldungen. Versuche es in ein paar Minuten erneut.',
+      manyfold_unreachable: 'Bambuddy erreicht Manyfold nicht. Prüfe die URL und ob Manyfold läuft.',
+      manyfold_forbidden: 'Manyfold hat den Zugriff verweigert. Prüfe, ob der Besitzer der Anwendung dieses Modell sehen kann.',
+      manyfold_not_found: 'Manyfold findet dieses Modell oder diese Datei nicht.',
+      manyfold_too_large: 'Die Datei ist größer als 200 MB.',
+      manyfold_not_importable: 'Nur 3MF-, STL- und STEP-Dateien können importiert werden.',
+      manyfold_bad_url: 'Gib die Manyfold-URL mit http:// oder https:// am Anfang ein.',
+      manyfold_secret_required: 'Gib das Client-Secret ein.',
+      manyfold_failed: 'Manyfold hat mit einem Fehler geantwortet.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Füge eine MakerWorld-Modell-URL ein, um es direkt aus Bambuddy zu importieren und zu drucken — ohne die Bambu Handy App zu öffnen.',
     description: 'Füge eine MakerWorld-Modell-URL ein, um es direkt aus Bambuddy zu importieren und zu drucken — ohne die Bambu Handy App zu öffnen.',

+ 74 - 0
frontend/src/i18n/locales/en.ts

@@ -12,6 +12,7 @@ export default {
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'File Manager',
     files: 'File Manager',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Model Sources',
     notifications: 'Notifications',
     notifications: 'Notifications',
     settings: 'Settings',
     settings: 'Settings',
     system: 'System',
     system: 'System',
@@ -7668,6 +7669,79 @@ export default {
     noHistory: 'No detections yet.',
     noHistory: 'No detections yet.',
   },
   },
 
 
+  modelSources: {
+    title: 'Model Sources',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Browse and search your Manyfold library and import its files to slice and print them.',
+    notConnectedTitle: 'Manyfold is not connected',
+    notConnectedBody: 'Ask an administrator to connect your Manyfold library.',
+    connectionTitle: 'Manyfold connection',
+    connectionHelp: 'In Manyfold, open Settings → API and create an application with the scopes "public" and "read". Enter its client ID and secret here. Bambuddy sees the models the application\'s owner can see.',
+    url: 'Manyfold URL',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'Client ID',
+    clientSecret: 'Client secret',
+    clientSecretStored: 'Stored. Leave empty to keep it.',
+    test: 'Test connection',
+    testOk: 'Connected. Models Bambuddy can see: {{count}}',
+    disconnect: 'Disconnect',
+    disconnectTitle: 'Disconnect Manyfold?',
+    disconnectBody: 'Bambuddy forgets the Manyfold URL, client ID and secret. Files you imported stay in your library.',
+    disconnected: 'Manyfold disconnected',
+    editConnection: 'Connection',
+    searchPlaceholder: 'Search models',
+    modelCount: 'Models: {{count}}',
+    noModels: 'No models found.',
+    previous: 'Previous',
+    next: 'Next',
+    page: 'Page {{page}}',
+    back: 'Back to models',
+    openInManyfold: 'Open in Manyfold',
+    license: 'License',
+    files: 'Files',
+    noFiles: 'This model has no files.',
+    importTo: 'Import to',
+    folderAuto: 'Manyfold folder',
+    import: 'Import',
+    importing: 'Importing…',
+    imported: 'Imported {{filename}}',
+    alreadyInLibrary: 'Already in your library',
+    inLibrary: 'In library',
+    showInLibrary: 'Show in library',
+    notImportable: 'Bambuddy can\'t slice or print this type',
+    selectAll: 'Select all',
+    selectFile: 'Select {{name}}',
+    importSelected: 'Import selected ({{count}})',
+    importAll: 'Import all',
+    importProgress: 'Importing {{current}}/{{total}}',
+    bulkDone: 'Imported: {{imported}} · already in library: {{existing}} · failed: {{failed}}',
+    bulkFailed: 'Not imported: {{names}}. {{reason}}',
+    bulkNothing: 'Nothing to import: every printable file is already in your library.',
+    selectModel: 'Select {{name}}',
+    selectPage: 'Select page',
+    modelsSelected: 'Models selected: {{count}}',
+    importModels: 'Import their files',
+    clearSelection: 'Clear selection',
+    collectProgress: 'Reading models {{current}}/{{total}}',
+    modelsUnreadable: 'Could not read: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold is not connected.',
+      manyfold_credentials: 'Manyfold did not accept the client ID or secret.',
+      manyfold_scope: 'The Manyfold application needs the "read" scope. Edit it in Manyfold under Settings → API.',
+      manyfold_rate_limited: 'Manyfold is limiting sign-ins. Try again in a few minutes.',
+      manyfold_unreachable: 'Bambuddy can\'t reach Manyfold. Check the URL and that Manyfold is running.',
+      manyfold_forbidden: 'Manyfold refused access. Check that the application\'s owner can see this model.',
+      manyfold_not_found: 'Manyfold can\'t find this model or file.',
+      manyfold_too_large: 'The file is larger than 200 MB.',
+      manyfold_not_importable: 'Only 3MF, STL and STEP files can be imported.',
+      manyfold_bad_url: 'Enter the Manyfold URL starting with http:// or https://.',
+      manyfold_secret_required: 'Enter the client secret.',
+      manyfold_failed: 'Manyfold answered with an error.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Paste a MakerWorld model URL to import and print it directly from Bambuddy — without leaving for the Bambu Handy app.',
     description: 'Paste a MakerWorld model URL to import and print it directly from Bambuddy — without leaving for the Bambu Handy app.',

+ 74 - 0
frontend/src/i18n/locales/es.ts

@@ -12,6 +12,7 @@ export default {
     finance: 'Finanzas',
     finance: 'Finanzas',
     files: 'Gestor de archivos',
     files: 'Gestor de archivos',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Fuentes de modelos',
     notifications: 'Notificaciones',
     notifications: 'Notificaciones',
     settings: 'Ajustes',
     settings: 'Ajustes',
     system: 'Sistema',
     system: 'Sistema',
@@ -7623,6 +7624,79 @@ export default {
     noHistory: 'Aún no hay detecciones.',
     noHistory: 'Aún no hay detecciones.',
   },
   },
 
 
+  modelSources: {
+    title: 'Fuentes de modelos',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Explora y busca en tu biblioteca de Manyfold e importa sus archivos para laminarlos e imprimirlos.',
+    notConnectedTitle: 'Manyfold no está conectado',
+    notConnectedBody: 'Pide a un administrador que conecte tu biblioteca de Manyfold.',
+    connectionTitle: 'Conexión con Manyfold',
+    connectionHelp: 'En Manyfold, abre Ajustes → API y crea una aplicación con los permisos "public" y "read". Introduce aquí su ID de cliente y su secreto. Bambuddy ve los modelos que puede ver el propietario de la aplicación.',
+    url: 'URL de Manyfold',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'ID de cliente',
+    clientSecret: 'Secreto de cliente',
+    clientSecretStored: 'Guardado. Déjalo vacío para conservarlo.',
+    test: 'Probar conexión',
+    testOk: 'Conectado. Modelos que Bambuddy puede ver: {{count}}',
+    disconnect: 'Desconectar',
+    disconnectTitle: '¿Desconectar Manyfold?',
+    disconnectBody: 'Bambuddy olvida la URL, el ID de cliente y el secreto de Manyfold. Los archivos importados se quedan en tu biblioteca.',
+    disconnected: 'Manyfold desconectado',
+    editConnection: 'Conexión',
+    searchPlaceholder: 'Buscar modelos',
+    modelCount: 'Modelos: {{count}}',
+    noModels: 'No se encontraron modelos.',
+    previous: 'Anterior',
+    next: 'Siguiente',
+    page: 'Página {{page}}',
+    back: 'Volver a los modelos',
+    openInManyfold: 'Abrir en Manyfold',
+    license: 'Licencia',
+    files: 'Archivos',
+    noFiles: 'Este modelo no tiene archivos.',
+    importTo: 'Importar a',
+    folderAuto: 'Carpeta "Manyfold"',
+    import: 'Importar',
+    importing: 'Importando…',
+    imported: '{{filename}} importado',
+    alreadyInLibrary: 'Ya está en tu biblioteca',
+    inLibrary: 'En la biblioteca',
+    showInLibrary: 'Mostrar en la biblioteca',
+    notImportable: 'Bambuddy no puede laminar ni imprimir este tipo de archivo',
+    selectAll: 'Seleccionar todo',
+    selectFile: 'Seleccionar {{name}}',
+    importSelected: 'Importar selección ({{count}})',
+    importAll: 'Importar todo',
+    importProgress: 'Importando {{current}}/{{total}}',
+    bulkDone: 'Importados: {{imported}} · ya en la biblioteca: {{existing}} · fallidos: {{failed}}',
+    bulkFailed: 'Sin importar: {{names}}. {{reason}}',
+    bulkNothing: 'Nada que importar: todos los archivos imprimibles ya están en tu biblioteca.',
+    selectModel: 'Seleccionar {{name}}',
+    selectPage: 'Seleccionar página',
+    modelsSelected: 'Modelos seleccionados: {{count}}',
+    importModels: 'Importar sus archivos',
+    clearSelection: 'Quitar selección',
+    collectProgress: 'Leyendo modelos {{current}}/{{total}}',
+    modelsUnreadable: 'No se pudieron leer: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold no está conectado.',
+      manyfold_credentials: 'Manyfold no aceptó el ID de cliente o el secreto.',
+      manyfold_scope: 'La aplicación de Manyfold necesita el permiso "read". Edítala en Manyfold en Ajustes → API.',
+      manyfold_rate_limited: 'Manyfold está limitando los inicios de sesión. Inténtalo de nuevo en unos minutos.',
+      manyfold_unreachable: 'Bambuddy no puede conectar con Manyfold. Comprueba la URL y que Manyfold esté en marcha.',
+      manyfold_forbidden: 'Manyfold denegó el acceso. Comprueba que el propietario de la aplicación pueda ver este modelo.',
+      manyfold_not_found: 'Manyfold no encuentra este modelo o archivo.',
+      manyfold_too_large: 'El archivo ocupa más de 200 MB.',
+      manyfold_not_importable: 'Solo se pueden importar archivos 3MF, STL y STEP.',
+      manyfold_bad_url: 'Introduce la URL de Manyfold empezando por http:// o https://.',
+      manyfold_secret_required: 'Introduce el secreto de cliente.',
+      manyfold_failed: 'Manyfold respondió con un error.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Pegue la URL de un modelo de MakerWorld para importarlo e imprimirlo directamente desde Bambuddy — sin tener que salir a la aplicación Bambu Handy.',
     description: 'Pegue la URL de un modelo de MakerWorld para importarlo e imprimirlo directamente desde Bambuddy — sin tener que salir a la aplicación Bambu Handy.',

+ 74 - 0
frontend/src/i18n/locales/fr.ts

@@ -12,6 +12,7 @@ export default {
     finance: 'Finances',
     finance: 'Finances',
     files: 'Gestionnaire de fichiers',
     files: 'Gestionnaire de fichiers',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Sources de modèles',
     notifications: 'Notifications',
     notifications: 'Notifications',
     settings: 'Paramètres',
     settings: 'Paramètres',
     system: 'Système',
     system: 'Système',
@@ -7602,6 +7603,79 @@ export default {
     historyTitle: 'Détections récentes',
     historyTitle: 'Détections récentes',
     noHistory: 'Aucune détection pour le moment.',
     noHistory: 'Aucune détection pour le moment.',
   },
   },
+  modelSources: {
+    title: 'Sources de modèles',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Parcourez et recherchez votre bibliothèque Manyfold, puis importez ses fichiers pour les trancher et les imprimer.',
+    notConnectedTitle: 'Manyfold n\'est pas connecté',
+    notConnectedBody: 'Demandez à un administrateur de connecter votre bibliothèque Manyfold.',
+    connectionTitle: 'Connexion à Manyfold',
+    connectionHelp: 'Dans Manyfold, ouvrez Paramètres → API et créez une application avec les autorisations "public" et "read". Saisissez ici son identifiant client et son secret. Bambuddy voit les modèles visibles par le propriétaire de l\'application.',
+    url: 'URL de Manyfold',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'Identifiant client',
+    clientSecret: 'Secret client',
+    clientSecretStored: 'Enregistré. Laissez vide pour le conserver.',
+    test: 'Tester la connexion',
+    testOk: 'Connecté. Modèles visibles par Bambuddy : {{count}}',
+    disconnect: 'Déconnecter',
+    disconnectTitle: 'Déconnecter Manyfold ?',
+    disconnectBody: 'Bambuddy oublie l\'URL, l\'identifiant client et le secret de Manyfold. Les fichiers importés restent dans votre bibliothèque.',
+    disconnected: 'Manyfold déconnecté',
+    editConnection: 'Connexion',
+    searchPlaceholder: 'Rechercher des modèles',
+    modelCount: 'Modèles : {{count}}',
+    noModels: 'Aucun modèle trouvé.',
+    previous: 'Précédent',
+    next: 'Suivant',
+    page: 'Page {{page}}',
+    back: 'Retour aux modèles',
+    openInManyfold: 'Ouvrir dans Manyfold',
+    license: 'Licence',
+    files: 'Fichiers',
+    noFiles: 'Ce modèle n\'a aucun fichier.',
+    importTo: 'Importer dans',
+    folderAuto: 'Dossier « Manyfold »',
+    import: 'Importer',
+    importing: 'Importation…',
+    imported: '{{filename}} importé',
+    alreadyInLibrary: 'Déjà dans votre bibliothèque',
+    inLibrary: 'Dans la bibliothèque',
+    showInLibrary: 'Afficher dans la bibliothèque',
+    notImportable: 'Bambuddy ne peut ni trancher ni imprimer ce type de fichier',
+    selectAll: 'Tout sélectionner',
+    selectFile: 'Sélectionner {{name}}',
+    importSelected: 'Importer la sélection ({{count}})',
+    importAll: 'Tout importer',
+    importProgress: 'Importation {{current}}/{{total}}',
+    bulkDone: 'Importés : {{imported}} · déjà dans la bibliothèque : {{existing}} · échecs : {{failed}}',
+    bulkFailed: 'Non importés : {{names}}. {{reason}}',
+    bulkNothing: 'Rien à importer : tous les fichiers imprimables sont déjà dans votre bibliothèque.',
+    selectModel: 'Sélectionner {{name}}',
+    selectPage: 'Sélectionner la page',
+    modelsSelected: 'Modèles sélectionnés : {{count}}',
+    importModels: 'Importer leurs fichiers',
+    clearSelection: 'Effacer la sélection',
+    collectProgress: 'Lecture des modèles {{current}}/{{total}}',
+    modelsUnreadable: 'Lecture impossible : {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold n\'est pas connecté.',
+      manyfold_credentials: 'Manyfold n\'a pas accepté l\'identifiant client ou le secret.',
+      manyfold_scope: 'L\'application Manyfold a besoin de l\'autorisation "read". Modifiez-la dans Manyfold sous Paramètres → API.',
+      manyfold_rate_limited: 'Manyfold limite les connexions. Réessayez dans quelques minutes.',
+      manyfold_unreachable: 'Bambuddy ne parvient pas à joindre Manyfold. Vérifiez l\'URL et que Manyfold fonctionne.',
+      manyfold_forbidden: 'Manyfold a refusé l\'accès. Vérifiez que le propriétaire de l\'application peut voir ce modèle.',
+      manyfold_not_found: 'Manyfold ne trouve pas ce modèle ou ce fichier.',
+      manyfold_too_large: 'Le fichier dépasse 200 Mo.',
+      manyfold_not_importable: 'Seuls les fichiers 3MF, STL et STEP peuvent être importés.',
+      manyfold_bad_url: 'Saisissez l\'URL de Manyfold en commençant par http:// ou https://.',
+      manyfold_secret_required: 'Saisissez le secret client.',
+      manyfold_failed: 'Manyfold a répondu par une erreur.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Collez une URL de modèle MakerWorld pour l\'importer et l\'imprimer directement depuis Bambuddy — sans passer par l\'application Bambu Handy.',
     description: 'Collez une URL de modèle MakerWorld pour l\'importer et l\'imprimer directement depuis Bambuddy — sans passer par l\'application Bambu Handy.',

+ 74 - 0
frontend/src/i18n/locales/it.ts

@@ -12,6 +12,7 @@ export default {
     finance: 'Finanze',
     finance: 'Finanze',
     files: 'File',
     files: 'File',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Fonti dei modelli',
     notifications: 'Notifiche',
     notifications: 'Notifiche',
     settings: 'Impostazioni',
     settings: 'Impostazioni',
     system: 'Sistema',
     system: 'Sistema',
@@ -7601,6 +7602,79 @@ export default {
     historyTitle: 'Rilevamenti recenti',
     historyTitle: 'Rilevamenti recenti',
     noHistory: 'Nessun rilevamento finora.',
     noHistory: 'Nessun rilevamento finora.',
   },
   },
+  modelSources: {
+    title: 'Fonti dei modelli',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Sfoglia e cerca nella tua libreria Manyfold e importa i suoi file per elaborarli e stamparli.',
+    notConnectedTitle: 'Manyfold non è collegato',
+    notConnectedBody: 'Chiedi a un amministratore di collegare la tua libreria Manyfold.',
+    connectionTitle: 'Connessione a Manyfold',
+    connectionHelp: 'In Manyfold apri Impostazioni → API e crea un\'applicazione con i permessi "public" e "read". Inserisci qui il suo ID client e il suo segreto. Bambuddy vede i modelli che il proprietario dell\'applicazione può vedere.',
+    url: 'URL di Manyfold',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'ID client',
+    clientSecret: 'Segreto client',
+    clientSecretStored: 'Salvato. Lascia vuoto per mantenerlo.',
+    test: 'Prova connessione',
+    testOk: 'Collegato. Modelli visibili a Bambuddy: {{count}}',
+    disconnect: 'Scollega',
+    disconnectTitle: 'Scollegare Manyfold?',
+    disconnectBody: 'Bambuddy dimentica URL, ID client e segreto di Manyfold. I file importati restano nella tua libreria.',
+    disconnected: 'Manyfold scollegato',
+    editConnection: 'Connessione',
+    searchPlaceholder: 'Cerca modelli',
+    modelCount: 'Modelli: {{count}}',
+    noModels: 'Nessun modello trovato.',
+    previous: 'Precedente',
+    next: 'Successiva',
+    page: 'Pagina {{page}}',
+    back: 'Torna ai modelli',
+    openInManyfold: 'Apri in Manyfold',
+    license: 'Licenza',
+    files: 'File',
+    noFiles: 'Questo modello non ha file.',
+    importTo: 'Importa in',
+    folderAuto: 'Cartella "Manyfold"',
+    import: 'Importa',
+    importing: 'Importazione…',
+    imported: '{{filename}} importato',
+    alreadyInLibrary: 'Già nella tua libreria',
+    inLibrary: 'Nella libreria',
+    showInLibrary: 'Mostra nella libreria',
+    notImportable: 'Bambuddy non può elaborare né stampare questo tipo di file',
+    selectAll: 'Seleziona tutto',
+    selectFile: 'Seleziona {{name}}',
+    importSelected: 'Importa selezionati ({{count}})',
+    importAll: 'Importa tutto',
+    importProgress: 'Importazione {{current}}/{{total}}',
+    bulkDone: 'Importati: {{imported}} · già nella libreria: {{existing}} · non riusciti: {{failed}}',
+    bulkFailed: 'Non importati: {{names}}. {{reason}}',
+    bulkNothing: 'Niente da importare: tutti i file stampabili sono già nella tua libreria.',
+    selectModel: 'Seleziona {{name}}',
+    selectPage: 'Seleziona pagina',
+    modelsSelected: 'Modelli selezionati: {{count}}',
+    importModels: 'Importa i loro file',
+    clearSelection: 'Annulla selezione',
+    collectProgress: 'Lettura modelli {{current}}/{{total}}',
+    modelsUnreadable: 'Impossibile leggere: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold non è collegato.',
+      manyfold_credentials: 'Manyfold non ha accettato l\'ID client o il segreto.',
+      manyfold_scope: 'L\'applicazione Manyfold ha bisogno del permesso "read". Modificala in Manyfold in Impostazioni → API.',
+      manyfold_rate_limited: 'Manyfold sta limitando gli accessi. Riprova tra qualche minuto.',
+      manyfold_unreachable: 'Bambuddy non riesce a raggiungere Manyfold. Controlla l\'URL e che Manyfold sia in esecuzione.',
+      manyfold_forbidden: 'Manyfold ha negato l\'accesso. Controlla che il proprietario dell\'applicazione possa vedere questo modello.',
+      manyfold_not_found: 'Manyfold non trova questo modello o file.',
+      manyfold_too_large: 'Il file supera i 200 MB.',
+      manyfold_not_importable: 'Si possono importare solo file 3MF, STL e STEP.',
+      manyfold_bad_url: 'Inserisci l\'URL di Manyfold che inizia con http:// o https://.',
+      manyfold_secret_required: 'Inserisci il segreto client.',
+      manyfold_failed: 'Manyfold ha risposto con un errore.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Incolla un URL di un modello MakerWorld per importarlo e stamparlo direttamente da Bambuddy — senza passare dall\'app Bambu Handy.',
     description: 'Incolla un URL di un modello MakerWorld per importarlo e stamparlo direttamente da Bambuddy — senza passare dall\'app Bambu Handy.',

+ 74 - 0
frontend/src/i18n/locales/ja.ts

@@ -12,6 +12,7 @@ export default {
     finance: 'ファイナンス',
     finance: 'ファイナンス',
     files: 'ファイル管理',
     files: 'ファイル管理',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'モデルソース',
     notifications: '通知',
     notifications: '通知',
     settings: '設定',
     settings: '設定',
     system: 'システム',
     system: 'システム',
@@ -7615,6 +7616,79 @@ export default {
     historyTitle: '最近の検出',
     historyTitle: '最近の検出',
     noHistory: 'まだ検出はありません。',
     noHistory: 'まだ検出はありません。',
   },
   },
+  modelSources: {
+    title: 'モデルソース',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Manyfold ライブラリを閲覧・検索し、ファイルをインポートしてスライス・印刷できます。',
+    notConnectedTitle: 'Manyfold は接続されていません',
+    notConnectedBody: 'Manyfold ライブラリの接続を管理者に依頼してください。',
+    connectionTitle: 'Manyfold の接続',
+    connectionHelp: 'Manyfold で「設定 → API」を開き、スコープ "public" と "read" を持つアプリケーションを作成してください。そのクライアント ID とシークレットをここに入力します。Bambuddy には、アプリケーションの所有者が見られるモデルが表示されます。',
+    url: 'Manyfold の URL',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'クライアント ID',
+    clientSecret: 'クライアントシークレット',
+    clientSecretStored: '保存済み。空欄のままにすると保持されます。',
+    test: '接続をテスト',
+    testOk: '接続しました。Bambuddy が参照できるモデル: {{count}}',
+    disconnect: '切断',
+    disconnectTitle: 'Manyfold を切断しますか?',
+    disconnectBody: 'Bambuddy は Manyfold の URL、クライアント ID、シークレットを削除します。インポート済みのファイルはライブラリに残ります。',
+    disconnected: 'Manyfold を切断しました',
+    editConnection: '接続',
+    searchPlaceholder: 'モデルを検索',
+    modelCount: 'モデル: {{count}}',
+    noModels: 'モデルが見つかりません。',
+    previous: '前へ',
+    next: '次へ',
+    page: '{{page}} ページ',
+    back: 'モデル一覧に戻る',
+    openInManyfold: 'Manyfold で開く',
+    license: 'ライセンス',
+    files: 'ファイル',
+    noFiles: 'このモデルにはファイルがありません。',
+    importTo: 'インポート先',
+    folderAuto: '「Manyfold」フォルダ',
+    import: 'インポート',
+    importing: 'インポート中…',
+    imported: '{{filename}} をインポートしました',
+    alreadyInLibrary: 'すでにライブラリにあります',
+    inLibrary: 'ライブラリにあります',
+    showInLibrary: 'ライブラリで表示',
+    notImportable: 'Bambuddy はこの種類のファイルをスライスも印刷もできません',
+    selectAll: 'すべて選択',
+    selectFile: '{{name}} を選択',
+    importSelected: '選択したものをインポート ({{count}})',
+    importAll: 'すべてインポート',
+    importProgress: 'インポート中 {{current}}/{{total}}',
+    bulkDone: 'インポート: {{imported}} · ライブラリに既存: {{existing}} · 失敗: {{failed}}',
+    bulkFailed: 'インポートされませんでした: {{names}}。{{reason}}',
+    bulkNothing: 'インポートするものはありません。印刷可能なファイルはすべてライブラリにあります。',
+    selectModel: '{{name}} を選択',
+    selectPage: 'ページを選択',
+    modelsSelected: '選択したモデル: {{count}}',
+    importModels: 'ファイルをインポート',
+    clearSelection: '選択を解除',
+    collectProgress: 'モデルを読み込み中 {{current}}/{{total}}',
+    modelsUnreadable: '読み込めませんでした: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold は接続されていません。',
+      manyfold_credentials: 'Manyfold がクライアント ID またはシークレットを受け付けませんでした。',
+      manyfold_scope: 'Manyfold のアプリケーションには "read" スコープが必要です。Manyfold の「設定 → API」で編集してください。',
+      manyfold_rate_limited: 'Manyfold がサインインを制限しています。数分後にもう一度お試しください。',
+      manyfold_unreachable: 'Bambuddy から Manyfold に接続できません。URL と Manyfold が動作しているかを確認してください。',
+      manyfold_forbidden: 'Manyfold がアクセスを拒否しました。アプリケーションの所有者がこのモデルを見られるか確認してください。',
+      manyfold_not_found: 'Manyfold にこのモデルまたはファイルが見つかりません。',
+      manyfold_too_large: 'ファイルが 200 MB を超えています。',
+      manyfold_not_importable: 'インポートできるのは 3MF、STL、STEP ファイルのみです。',
+      manyfold_bad_url: 'http:// または https:// で始まる Manyfold の URL を入力してください。',
+      manyfold_secret_required: 'クライアントシークレットを入力してください。',
+      manyfold_failed: 'Manyfold がエラーを返しました。',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'MakerWorld モデルの URL を貼り付けると、Bambu Handy アプリを開かなくても Bambuddy から直接インポート・印刷できます。',
     description: 'MakerWorld モデルの URL を貼り付けると、Bambu Handy アプリを開かなくても Bambuddy から直接インポート・印刷できます。',

+ 74 - 0
frontend/src/i18n/locales/ko.ts

@@ -11,6 +11,7 @@ export default {
     finance: '재무',
     finance: '재무',
     files: '파일 관리자',
     files: '파일 관리자',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: '모델 소스',
     notifications: '알림',
     notifications: '알림',
     settings: '설정',
     settings: '설정',
     system: '시스템',
     system: '시스템',
@@ -7056,6 +7057,79 @@ export default {
     historyTitle: '최근 감지',
     historyTitle: '최근 감지',
     noHistory: '아직 감지 없음.'
     noHistory: '아직 감지 없음.'
   },
   },
+  modelSources: {
+    title: '모델 소스',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Manyfold 라이브러리를 둘러보고 검색한 뒤, 파일을 가져와 슬라이스하고 출력하세요.',
+    notConnectedTitle: 'Manyfold가 연결되지 않았습니다',
+    notConnectedBody: '관리자에게 Manyfold 라이브러리 연결을 요청하세요.',
+    connectionTitle: 'Manyfold 연결',
+    connectionHelp: 'Manyfold에서 설정 → API를 열고 "public" 및 "read" 범위를 가진 애플리케이션을 만드세요. 그 클라이언트 ID와 시크릿을 여기에 입력합니다. Bambuddy에는 애플리케이션 소유자가 볼 수 있는 모델이 표시됩니다.',
+    url: 'Manyfold 주소',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: '클라이언트 ID',
+    clientSecret: '클라이언트 시크릿',
+    clientSecretStored: '저장됨. 비워 두면 그대로 유지됩니다.',
+    test: '연결 테스트',
+    testOk: '연결되었습니다. Bambuddy가 볼 수 있는 모델: {{count}}',
+    disconnect: '연결 해제',
+    disconnectTitle: 'Manyfold 연결을 해제할까요?',
+    disconnectBody: 'Bambuddy가 Manyfold URL, 클라이언트 ID, 시크릿을 삭제합니다. 가져온 파일은 라이브러리에 남습니다.',
+    disconnected: 'Manyfold 연결이 해제되었습니다',
+    editConnection: '연결',
+    searchPlaceholder: '모델 검색',
+    modelCount: '모델: {{count}}',
+    noModels: '모델을 찾을 수 없습니다.',
+    previous: '이전',
+    next: '다음',
+    page: '{{page}} 페이지',
+    back: '모델 목록으로',
+    openInManyfold: 'Manyfold에서 열기',
+    license: '라이선스',
+    files: '파일',
+    noFiles: '이 모델에는 파일이 없습니다.',
+    importTo: '가져올 위치',
+    folderAuto: '"Manyfold" 폴더',
+    import: '가져오기',
+    importing: '가져오는 중…',
+    imported: '{{filename}} 가져옴',
+    alreadyInLibrary: '이미 라이브러리에 있습니다',
+    inLibrary: '라이브러리에 있음',
+    showInLibrary: '라이브러리에서 보기',
+    notImportable: 'Bambuddy는 이 파일 형식을 슬라이스하거나 출력할 수 없습니다',
+    selectAll: '모두 선택',
+    selectFile: '{{name}} 선택',
+    importSelected: '선택 항목 가져오기 ({{count}})',
+    importAll: '모두 가져오기',
+    importProgress: '가져오는 중 {{current}}/{{total}}',
+    bulkDone: '가져옴: {{imported}} · 이미 라이브러리에 있음: {{existing}} · 실패: {{failed}}',
+    bulkFailed: '가져오지 못함: {{names}}. {{reason}}',
+    bulkNothing: '가져올 항목이 없습니다. 출력 가능한 파일이 모두 라이브러리에 있습니다.',
+    selectModel: '{{name}} 선택',
+    selectPage: '페이지 선택',
+    modelsSelected: '선택한 모델: {{count}}',
+    importModels: '파일 가져오기',
+    clearSelection: '선택 해제',
+    collectProgress: '모델 읽는 중 {{current}}/{{total}}',
+    modelsUnreadable: '읽을 수 없음: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold가 연결되지 않았습니다.',
+      manyfold_credentials: 'Manyfold가 클라이언트 ID 또는 시크릿을 받아들이지 않았습니다.',
+      manyfold_scope: 'Manyfold 애플리케이션에 "read" 범위가 필요합니다. Manyfold의 설정 → API에서 수정하세요.',
+      manyfold_rate_limited: 'Manyfold가 로그인을 제한하고 있습니다. 몇 분 후에 다시 시도하세요.',
+      manyfold_unreachable: 'Bambuddy가 Manyfold에 연결할 수 없습니다. URL과 Manyfold 실행 여부를 확인하세요.',
+      manyfold_forbidden: 'Manyfold가 접근을 거부했습니다. 애플리케이션 소유자가 이 모델을 볼 수 있는지 확인하세요.',
+      manyfold_not_found: 'Manyfold에서 이 모델이나 파일을 찾을 수 없습니다.',
+      manyfold_too_large: '파일이 200 MB보다 큽니다.',
+      manyfold_not_importable: '3MF, STL, STEP 파일만 가져올 수 있습니다.',
+      manyfold_bad_url: 'http:// 또는 https://로 시작하는 Manyfold URL을 입력하세요.',
+      manyfold_secret_required: '클라이언트 시크릿을 입력하세요.',
+      manyfold_failed: 'Manyfold가 오류로 응답했습니다.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'MakerWorld 모델 URL을 붙여넣어 Bambu Handy 앱을 떠나지 않고 Bambuddy에서 직접 가져오고 인쇄하세요.',
     description: 'MakerWorld 모델 URL을 붙여넣어 Bambu Handy 앱을 떠나지 않고 Bambuddy에서 직접 가져오고 인쇄하세요.',

+ 74 - 0
frontend/src/i18n/locales/nl.ts

@@ -12,6 +12,7 @@ export default {
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'Bestandsbeheer',
     files: 'Bestandsbeheer',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Modelbronnen',
     notifications: 'Meldingen',
     notifications: 'Meldingen',
     settings: 'Instellingen',
     settings: 'Instellingen',
     system: 'Systeem',
     system: 'Systeem',
@@ -7666,6 +7667,79 @@ export default {
     noHistory: 'Nog geen detecties.',
     noHistory: 'Nog geen detecties.',
   },
   },
 
 
+  modelSources: {
+    title: 'Modelbronnen',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Blader en zoek in je Manyfold-bibliotheek en importeer de bestanden om ze te slicen en te printen.',
+    notConnectedTitle: 'Manyfold is niet verbonden',
+    notConnectedBody: 'Vraag een beheerder om je Manyfold-bibliotheek te verbinden.',
+    connectionTitle: 'Manyfold-verbinding',
+    connectionHelp: 'Open in Manyfold Instellingen → API en maak een applicatie met de rechten "public" en "read". Vul hier de client-ID en het geheim in. Bambuddy ziet de modellen die de eigenaar van de applicatie kan zien.',
+    url: 'Manyfold-URL',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'Client-ID',
+    clientSecret: 'Clientgeheim',
+    clientSecretStored: 'Opgeslagen. Laat leeg om het te behouden.',
+    test: 'Verbinding testen',
+    testOk: 'Verbonden. Modellen die Bambuddy kan zien: {{count}}',
+    disconnect: 'Verbinding verbreken',
+    disconnectTitle: 'Verbinding met Manyfold verbreken?',
+    disconnectBody: 'Bambuddy vergeet de URL, client-ID en het geheim van Manyfold. Geïmporteerde bestanden blijven in je bibliotheek.',
+    disconnected: 'Verbinding met Manyfold verbroken',
+    editConnection: 'Verbinding',
+    searchPlaceholder: 'Modellen zoeken',
+    modelCount: 'Modellen: {{count}}',
+    noModels: 'Geen modellen gevonden.',
+    previous: 'Vorige',
+    next: 'Volgende',
+    page: 'Pagina {{page}}',
+    back: 'Terug naar de modellen',
+    openInManyfold: 'Openen in Manyfold',
+    license: 'Licentie',
+    files: 'Bestanden',
+    noFiles: 'Dit model heeft geen bestanden.',
+    importTo: 'Importeren naar',
+    folderAuto: 'Map "Manyfold"',
+    import: 'Importeren',
+    importing: 'Bezig met importeren…',
+    imported: '{{filename}} geïmporteerd',
+    alreadyInLibrary: 'Staat al in je bibliotheek',
+    inLibrary: 'In de bibliotheek',
+    showInLibrary: 'Tonen in de bibliotheek',
+    notImportable: 'Bambuddy kan dit bestandstype niet slicen of printen',
+    selectAll: 'Alles selecteren',
+    selectFile: '{{name}} selecteren',
+    importSelected: 'Selectie importeren ({{count}})',
+    importAll: 'Alles importeren',
+    importProgress: 'Importeren {{current}}/{{total}}',
+    bulkDone: 'Geïmporteerd: {{imported}} · al in de bibliotheek: {{existing}} · mislukt: {{failed}}',
+    bulkFailed: 'Niet geïmporteerd: {{names}}. {{reason}}',
+    bulkNothing: 'Niets te importeren: elk printbaar bestand staat al in je bibliotheek.',
+    selectModel: '{{name}} selecteren',
+    selectPage: 'Pagina selecteren',
+    modelsSelected: 'Geselecteerde modellen: {{count}}',
+    importModels: 'Hun bestanden importeren',
+    clearSelection: 'Selectie wissen',
+    collectProgress: 'Modellen lezen {{current}}/{{total}}',
+    modelsUnreadable: 'Kon niet lezen: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold is niet verbonden.',
+      manyfold_credentials: 'Manyfold accepteerde de client-ID of het geheim niet.',
+      manyfold_scope: 'De Manyfold-applicatie heeft het recht "read" nodig. Pas dit aan in Manyfold onder Instellingen → API.',
+      manyfold_rate_limited: 'Manyfold beperkt het aantal aanmeldingen. Probeer het over een paar minuten opnieuw.',
+      manyfold_unreachable: 'Bambuddy kan Manyfold niet bereiken. Controleer de URL en of Manyfold draait.',
+      manyfold_forbidden: 'Manyfold weigerde de toegang. Controleer of de eigenaar van de applicatie dit model kan zien.',
+      manyfold_not_found: 'Manyfold kan dit model of bestand niet vinden.',
+      manyfold_too_large: 'Het bestand is groter dan 200 MB.',
+      manyfold_not_importable: 'Alleen 3MF-, STL- en STEP-bestanden kunnen worden geïmporteerd.',
+      manyfold_bad_url: 'Vul de Manyfold-URL in, beginnend met http:// of https://.',
+      manyfold_secret_required: 'Vul het clientgeheim in.',
+      manyfold_failed: 'Manyfold antwoordde met een fout.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Plak een MakerWorld-model-URL om het rechtstreeks vanuit Bambuddy te importeren en af te drukken — zonder naar de Bambu Handy-app te gaan.',
     description: 'Plak een MakerWorld-model-URL om het rechtstreeks vanuit Bambuddy te importeren en af te drukken — zonder naar de Bambu Handy-app te gaan.',

+ 74 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -12,6 +12,7 @@ export default {
     finance: 'Finanças',
     finance: 'Finanças',
     files: 'Gerenciador de Arquivos',
     files: 'Gerenciador de Arquivos',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Fontes de modelos',
     notifications: 'Notificações',
     notifications: 'Notificações',
     settings: 'Configurações',
     settings: 'Configurações',
     system: 'Sistema',
     system: 'Sistema',
@@ -7601,6 +7602,79 @@ export default {
     historyTitle: 'Detecções recentes',
     historyTitle: 'Detecções recentes',
     noHistory: 'Nenhuma detecção ainda.',
     noHistory: 'Nenhuma detecção ainda.',
   },
   },
+  modelSources: {
+    title: 'Fontes de modelos',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Navegue e pesquise na sua biblioteca do Manyfold e importe os arquivos para fatiar e imprimir.',
+    notConnectedTitle: 'O Manyfold não está conectado',
+    notConnectedBody: 'Peça a um administrador para conectar sua biblioteca do Manyfold.',
+    connectionTitle: 'Conexão com o Manyfold',
+    connectionHelp: 'No Manyfold, abra Configurações → API e crie um aplicativo com os escopos "public" e "read". Informe aqui o ID do cliente e o segredo dele. O Bambuddy vê os modelos que o dono do aplicativo pode ver.',
+    url: 'URL do Manyfold',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'ID do cliente',
+    clientSecret: 'Segredo do cliente',
+    clientSecretStored: 'Salvo. Deixe vazio para mantê-lo.',
+    test: 'Testar conexão',
+    testOk: 'Conectado. Modelos que o Bambuddy pode ver: {{count}}',
+    disconnect: 'Desconectar',
+    disconnectTitle: 'Desconectar o Manyfold?',
+    disconnectBody: 'O Bambuddy esquece a URL, o ID do cliente e o segredo do Manyfold. Os arquivos importados continuam na sua biblioteca.',
+    disconnected: 'Manyfold desconectado',
+    editConnection: 'Conexão',
+    searchPlaceholder: 'Pesquisar modelos',
+    modelCount: 'Modelos: {{count}}',
+    noModels: 'Nenhum modelo encontrado.',
+    previous: 'Anterior',
+    next: 'Próxima',
+    page: 'Página {{page}}',
+    back: 'Voltar aos modelos',
+    openInManyfold: 'Abrir no Manyfold',
+    license: 'Licença',
+    files: 'Arquivos',
+    noFiles: 'Este modelo não tem arquivos.',
+    importTo: 'Importar para',
+    folderAuto: 'Pasta "Manyfold"',
+    import: 'Importar',
+    importing: 'Importando…',
+    imported: '{{filename}} importado',
+    alreadyInLibrary: 'Já está na sua biblioteca',
+    inLibrary: 'Na biblioteca',
+    showInLibrary: 'Mostrar na biblioteca',
+    notImportable: 'O Bambuddy não consegue fatiar nem imprimir este tipo de arquivo',
+    selectAll: 'Selecionar tudo',
+    selectFile: 'Selecionar {{name}}',
+    importSelected: 'Importar seleção ({{count}})',
+    importAll: 'Importar tudo',
+    importProgress: 'Importando {{current}}/{{total}}',
+    bulkDone: 'Importados: {{imported}} · já na biblioteca: {{existing}} · com falha: {{failed}}',
+    bulkFailed: 'Não importados: {{names}}. {{reason}}',
+    bulkNothing: 'Nada para importar: todos os arquivos imprimíveis já estão na sua biblioteca.',
+    selectModel: 'Selecionar {{name}}',
+    selectPage: 'Selecionar página',
+    modelsSelected: 'Modelos selecionados: {{count}}',
+    importModels: 'Importar os arquivos deles',
+    clearSelection: 'Limpar seleção',
+    collectProgress: 'Lendo modelos {{current}}/{{total}}',
+    modelsUnreadable: 'Não foi possível ler: {{names}}',
+    errors: {
+      manyfold_not_configured: 'O Manyfold não está conectado.',
+      manyfold_credentials: 'O Manyfold não aceitou o ID do cliente ou o segredo.',
+      manyfold_scope: 'O aplicativo do Manyfold precisa do escopo "read". Edite-o no Manyfold em Configurações → API.',
+      manyfold_rate_limited: 'O Manyfold está limitando os logins. Tente novamente em alguns minutos.',
+      manyfold_unreachable: 'O Bambuddy não consegue acessar o Manyfold. Verifique a URL e se o Manyfold está em execução.',
+      manyfold_forbidden: 'O Manyfold recusou o acesso. Verifique se o dono do aplicativo pode ver este modelo.',
+      manyfold_not_found: 'O Manyfold não encontra este modelo ou arquivo.',
+      manyfold_too_large: 'O arquivo tem mais de 200 MB.',
+      manyfold_not_importable: 'Só é possível importar arquivos 3MF, STL e STEP.',
+      manyfold_bad_url: 'Informe a URL do Manyfold começando com http:// ou https://.',
+      manyfold_secret_required: 'Informe o segredo do cliente.',
+      manyfold_failed: 'O Manyfold respondeu com um erro.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Cole uma URL de modelo do MakerWorld para importar e imprimir diretamente do Bambuddy — sem precisar abrir o app Bambu Handy.',
     description: 'Cole uma URL de modelo do MakerWorld para importar e imprimir diretamente do Bambuddy — sem precisar abrir o app Bambu Handy.',

+ 74 - 0
frontend/src/i18n/locales/ru.ts

@@ -11,6 +11,7 @@ export default {
     finance: "Финансы",
     finance: "Финансы",
     files: "Файловый менеджер",
     files: "Файловый менеджер",
     makerworld: "MakerWorld",
     makerworld: "MakerWorld",
+    modelSources: "Источники моделей",
     notifications: "Уведомления",
     notifications: "Уведомления",
     settings: "Настройки",
     settings: "Настройки",
     system: "Система",
     system: "Система",
@@ -7237,6 +7238,79 @@ export default {
     historyTitle: "Последние обнаружения",
     historyTitle: "Последние обнаружения",
     noHistory: "Обнаружений пока нет.",
     noHistory: "Обнаружений пока нет.",
   },
   },
+  modelSources: {
+    title: "Источники моделей",
+    tabMakerworld: "MakerWorld",
+    tabManyfold: "Manyfold",
+  },
+  manyfold: {
+    description: "Просматривайте библиотеку Manyfold, ищите в ней и импортируйте файлы, чтобы нарезать и напечатать их.",
+    notConnectedTitle: "Manyfold не подключён",
+    notConnectedBody: "Попросите администратора подключить вашу библиотеку Manyfold.",
+    connectionTitle: "Подключение к Manyfold",
+    connectionHelp: "В Manyfold откройте «Настройки → API» и создайте приложение с областями \"public\" и \"read\". Введите здесь его ID клиента и секрет. Bambuddy видит модели, которые может видеть владелец приложения.",
+    url: "URL Manyfold",
+    urlPlaceholder: "http://192.168.1.10:3214",
+    clientId: "ID клиента",
+    clientSecret: "Секрет клиента",
+    clientSecretStored: "Сохранён. Оставьте пустым, чтобы сохранить его.",
+    test: "Проверить подключение",
+    testOk: "Подключено. Моделей видно Bambuddy: {{count}}",
+    disconnect: "Отключить",
+    disconnectTitle: "Отключить Manyfold?",
+    disconnectBody: "Bambuddy забудет URL, ID клиента и секрет Manyfold. Импортированные файлы останутся в вашей библиотеке.",
+    disconnected: "Manyfold отключён",
+    editConnection: "Подключение",
+    searchPlaceholder: "Поиск моделей",
+    modelCount: "Моделей: {{count}}",
+    noModels: "Модели не найдены.",
+    previous: "Назад",
+    next: "Далее",
+    page: "Страница {{page}}",
+    back: "К списку моделей",
+    openInManyfold: "Открыть в Manyfold",
+    license: "Лицензия",
+    files: "Файлы",
+    noFiles: "У этой модели нет файлов.",
+    importTo: "Импортировать в",
+    folderAuto: "Папка «Manyfold»",
+    import: "Импортировать",
+    importing: "Импорт…",
+    imported: "{{filename}} импортирован",
+    alreadyInLibrary: "Уже в вашей библиотеке",
+    inLibrary: "В библиотеке",
+    showInLibrary: "Показать в библиотеке",
+    notImportable: "Bambuddy не может нарезать или напечатать файл этого типа",
+    selectAll: "Выбрать все",
+    selectFile: "Выбрать {{name}}",
+    importSelected: "Импортировать выбранное ({{count}})",
+    importAll: "Импортировать все",
+    importProgress: "Импорт {{current}}/{{total}}",
+    bulkDone: "Импортировано: {{imported}} · уже в библиотеке: {{existing}} · с ошибкой: {{failed}}",
+    bulkFailed: "Не импортировано: {{names}}. {{reason}}",
+    bulkNothing: "Импортировать нечего: все файлы для печати уже в вашей библиотеке.",
+    selectModel: "Выбрать {{name}}",
+    selectPage: "Выбрать страницу",
+    modelsSelected: "Выбрано моделей: {{count}}",
+    importModels: "Импортировать их файлы",
+    clearSelection: "Снять выбор",
+    collectProgress: "Чтение моделей {{current}}/{{total}}",
+    modelsUnreadable: "Не удалось прочитать: {{names}}",
+    errors: {
+      manyfold_not_configured: "Manyfold не подключён.",
+      manyfold_credentials: "Manyfold не принял ID клиента или секрет.",
+      manyfold_scope: "Приложению Manyfold нужна область \"read\". Измените его в Manyfold в разделе «Настройки → API».",
+      manyfold_rate_limited: "Manyfold ограничивает входы. Повторите попытку через несколько минут.",
+      manyfold_unreachable: "Bambuddy не может связаться с Manyfold. Проверьте URL и работает ли Manyfold.",
+      manyfold_forbidden: "Manyfold отказал в доступе. Проверьте, видит ли владелец приложения эту модель.",
+      manyfold_not_found: "Manyfold не находит эту модель или файл.",
+      manyfold_too_large: "Файл больше 200 МБ.",
+      manyfold_not_importable: "Импортировать можно только файлы 3MF, STL и STEP.",
+      manyfold_bad_url: "Введите URL Manyfold, начинающийся с http:// или https://.",
+      manyfold_secret_required: "Введите секрет клиента.",
+      manyfold_failed: "Manyfold ответил ошибкой.",
+    },
+  },
   makerworld: {
   makerworld: {
     title: "MakerWorld",
     title: "MakerWorld",
     description: "Вставьте URL модели MakerWorld, чтобы импортировать и напечатать её прямо из Bambuddy — без перехода в приложение Bambu Handy.",
     description: "Вставьте URL модели MakerWorld, чтобы импортировать и напечатать её прямо из Bambuddy — без перехода в приложение Bambu Handy.",

+ 74 - 0
frontend/src/i18n/locales/sv.ts

@@ -12,6 +12,7 @@ export default {
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'Filhanterare',
     files: 'Filhanterare',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Modellkällor',
     notifications: 'Notiser',
     notifications: 'Notiser',
     settings: 'Inställningar',
     settings: 'Inställningar',
     system: 'System',
     system: 'System',
@@ -7664,6 +7665,79 @@ errors: {
     noHistory: 'Inga upptäckter än.',
     noHistory: 'Inga upptäckter än.',
   },
   },
 
 
+  modelSources: {
+    title: 'Modellkällor',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Bläddra och sök i ditt Manyfold-bibliotek och importera filerna för att slica och skriva ut dem.',
+    notConnectedTitle: 'Manyfold är inte anslutet',
+    notConnectedBody: 'Be en administratör att ansluta ditt Manyfold-bibliotek.',
+    connectionTitle: 'Manyfold-anslutning',
+    connectionHelp: 'Öppna Inställningar → API i Manyfold och skapa en applikation med behörigheterna "public" och "read". Ange dess klient-ID och hemlighet här. Bambuddy ser de modeller som applikationens ägare kan se.',
+    url: 'Manyfold-URL',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'Klient-ID',
+    clientSecret: 'Klienthemlighet',
+    clientSecretStored: 'Sparad. Lämna tomt för att behålla den.',
+    test: 'Testa anslutningen',
+    testOk: 'Ansluten. Modeller som Bambuddy kan se: {{count}}',
+    disconnect: 'Koppla från',
+    disconnectTitle: 'Koppla från Manyfold?',
+    disconnectBody: 'Bambuddy glömmer Manyfolds URL, klient-ID och hemlighet. Importerade filer ligger kvar i ditt bibliotek.',
+    disconnected: 'Manyfold frånkopplat',
+    editConnection: 'Anslutning',
+    searchPlaceholder: 'Sök modeller',
+    modelCount: 'Modeller: {{count}}',
+    noModels: 'Inga modeller hittades.',
+    previous: 'Föregående',
+    next: 'Nästa',
+    page: 'Sida {{page}}',
+    back: 'Tillbaka till modellerna',
+    openInManyfold: 'Öppna i Manyfold',
+    license: 'Licens',
+    files: 'Filer',
+    noFiles: 'Den här modellen har inga filer.',
+    importTo: 'Importera till',
+    folderAuto: 'Mappen "Manyfold"',
+    import: 'Importera',
+    importing: 'Importerar…',
+    imported: '{{filename}} importerad',
+    alreadyInLibrary: 'Finns redan i ditt bibliotek',
+    inLibrary: 'I biblioteket',
+    showInLibrary: 'Visa i biblioteket',
+    notImportable: 'Bambuddy kan inte slica eller skriva ut den här filtypen',
+    selectAll: 'Markera alla',
+    selectFile: 'Markera {{name}}',
+    importSelected: 'Importera markerade ({{count}})',
+    importAll: 'Importera alla',
+    importProgress: 'Importerar {{current}}/{{total}}',
+    bulkDone: 'Importerade: {{imported}} · redan i biblioteket: {{existing}} · misslyckades: {{failed}}',
+    bulkFailed: 'Inte importerade: {{names}}. {{reason}}',
+    bulkNothing: 'Inget att importera: alla utskrivbara filer finns redan i ditt bibliotek.',
+    selectModel: 'Markera {{name}}',
+    selectPage: 'Markera sidan',
+    modelsSelected: 'Markerade modeller: {{count}}',
+    importModels: 'Importera deras filer',
+    clearSelection: 'Rensa markering',
+    collectProgress: 'Läser modeller {{current}}/{{total}}',
+    modelsUnreadable: 'Kunde inte läsa: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold är inte anslutet.',
+      manyfold_credentials: 'Manyfold godtog inte klient-ID:t eller hemligheten.',
+      manyfold_scope: 'Manyfold-applikationen behöver behörigheten "read". Ändra den i Manyfold under Inställningar → API.',
+      manyfold_rate_limited: 'Manyfold begränsar inloggningarna. Försök igen om några minuter.',
+      manyfold_unreachable: 'Bambuddy når inte Manyfold. Kontrollera URL:en och att Manyfold körs.',
+      manyfold_forbidden: 'Manyfold nekade åtkomst. Kontrollera att applikationens ägare kan se den här modellen.',
+      manyfold_not_found: 'Manyfold hittar inte den här modellen eller filen.',
+      manyfold_too_large: 'Filen är större än 200 MB.',
+      manyfold_not_importable: 'Endast 3MF-, STL- och STEP-filer kan importeras.',
+      manyfold_bad_url: 'Ange Manyfold-URL:en med http:// eller https:// i början.',
+      manyfold_secret_required: 'Ange klienthemligheten.',
+      manyfold_failed: 'Manyfold svarade med ett fel.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Klistra in en MakerWorld-modell-URL för att importera och skriva ut den direkt från Bambuddy — utan att lämna för Bambu Handy-app.',
     description: 'Klistra in en MakerWorld-modell-URL för att importera och skriva ut den direkt från Bambuddy — utan att lämna för Bambu Handy-app.',

+ 74 - 0
frontend/src/i18n/locales/tr.ts

@@ -12,6 +12,7 @@ export default {
     finance: 'Finans',
     finance: 'Finans',
     files: 'Dosya Yöneticisi',
     files: 'Dosya Yöneticisi',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: 'Model Kaynakları',
     notifications: 'Bildirimler',
     notifications: 'Bildirimler',
     settings: 'Ayarlar',
     settings: 'Ayarlar',
     system: 'Sistem',
     system: 'Sistem',
@@ -7552,6 +7553,79 @@ export default {
     noHistory: 'Henüz algılama yok.',
     noHistory: 'Henüz algılama yok.',
   },
   },
 
 
+  modelSources: {
+    title: 'Model Kaynakları',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: 'Manyfold kitaplığınıza göz atın, arayın ve dosyalarını dilimleyip yazdırmak için içe aktarın.',
+    notConnectedTitle: 'Manyfold bağlı değil',
+    notConnectedBody: 'Manyfold kitaplığınızı bağlaması için bir yöneticiye başvurun.',
+    connectionTitle: 'Manyfold bağlantısı',
+    connectionHelp: 'Manyfold\'da Ayarlar → API\'yi açın ve "public" ile "read" kapsamlarına sahip bir uygulama oluşturun. İstemci kimliğini ve gizli anahtarını buraya girin. Bambuddy, uygulama sahibinin görebildiği modelleri görür.',
+    url: 'Manyfold URL\'si',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: 'İstemci kimliği',
+    clientSecret: 'İstemci gizli anahtarı',
+    clientSecretStored: 'Kaydedildi. Korumak için boş bırakın.',
+    test: 'Bağlantıyı test et',
+    testOk: 'Bağlandı. Bambuddy\'nin görebildiği modeller: {{count}}',
+    disconnect: 'Bağlantıyı kes',
+    disconnectTitle: 'Manyfold bağlantısı kesilsin mi?',
+    disconnectBody: 'Bambuddy, Manyfold URL\'sini, istemci kimliğini ve gizli anahtarını unutur. İçe aktardığınız dosyalar kitaplığınızda kalır.',
+    disconnected: 'Manyfold bağlantısı kesildi',
+    editConnection: 'Bağlantı',
+    searchPlaceholder: 'Model ara',
+    modelCount: 'Modeller: {{count}}',
+    noModels: 'Model bulunamadı.',
+    previous: 'Önceki',
+    next: 'Sonraki',
+    page: 'Sayfa {{page}}',
+    back: 'Modellere dön',
+    openInManyfold: 'Manyfold\'da aç',
+    license: 'Lisans',
+    files: 'Dosyalar',
+    noFiles: 'Bu modelin dosyası yok.',
+    importTo: 'İçe aktarılacak yer',
+    folderAuto: '"Manyfold" klasörü',
+    import: 'İçe aktar',
+    importing: 'İçe aktarılıyor…',
+    imported: '{{filename}} içe aktarıldı',
+    alreadyInLibrary: 'Zaten kitaplığınızda',
+    inLibrary: 'Kitaplıkta',
+    showInLibrary: 'Kitaplıkta göster',
+    notImportable: 'Bambuddy bu dosya türünü dilimleyemez veya yazdıramaz',
+    selectAll: 'Tümünü seç',
+    selectFile: '{{name}} öğesini seç',
+    importSelected: 'Seçilenleri içe aktar ({{count}})',
+    importAll: 'Tümünü içe aktar',
+    importProgress: 'İçe aktarılıyor {{current}}/{{total}}',
+    bulkDone: 'İçe aktarılan: {{imported}} · zaten kitaplıkta: {{existing}} · başarısız: {{failed}}',
+    bulkFailed: 'İçe aktarılamadı: {{names}}. {{reason}}',
+    bulkNothing: 'İçe aktarılacak bir şey yok: yazdırılabilir tüm dosyalar zaten kitaplığınızda.',
+    selectModel: '{{name}} öğesini seç',
+    selectPage: 'Sayfayı seç',
+    modelsSelected: 'Seçilen modeller: {{count}}',
+    importModels: 'Dosyalarını içe aktar',
+    clearSelection: 'Seçimi temizle',
+    collectProgress: 'Modeller okunuyor {{current}}/{{total}}',
+    modelsUnreadable: 'Okunamadı: {{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold bağlı değil.',
+      manyfold_credentials: 'Manyfold istemci kimliğini veya gizli anahtarı kabul etmedi.',
+      manyfold_scope: 'Manyfold uygulamasının "read" kapsamına ihtiyacı var. Manyfold\'da Ayarlar → API altından düzenleyin.',
+      manyfold_rate_limited: 'Manyfold oturum açmaları sınırlıyor. Birkaç dakika sonra yeniden deneyin.',
+      manyfold_unreachable: 'Bambuddy, Manyfold\'a ulaşamıyor. URL\'yi ve Manyfold\'un çalıştığını kontrol edin.',
+      manyfold_forbidden: 'Manyfold erişimi reddetti. Uygulama sahibinin bu modeli görebildiğini kontrol edin.',
+      manyfold_not_found: 'Manyfold bu modeli veya dosyayı bulamıyor.',
+      manyfold_too_large: 'Dosya 200 MB\'tan büyük.',
+      manyfold_not_importable: 'Yalnızca 3MF, STL ve STEP dosyaları içe aktarılabilir.',
+      manyfold_bad_url: 'http:// veya https:// ile başlayan Manyfold URL\'sini girin.',
+      manyfold_secret_required: 'İstemci gizli anahtarını girin.',
+      manyfold_failed: 'Manyfold bir hatayla yanıt verdi.',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: 'Bambu Handy uygulamasına gitmeden — doğrudan Bambuddy\'den içe aktarmak ve yazdırmak için bir MakerWorld model URL\'si yapıştırın.',
     description: 'Bambu Handy uygulamasına gitmeden — doğrudan Bambuddy\'den içe aktarmak ve yazdırmak için bir MakerWorld model URL\'si yapıştırın.',

+ 74 - 0
frontend/src/i18n/locales/uk.ts

@@ -12,6 +12,7 @@ export default {
     finance: "Фінанси",
     finance: "Фінанси",
     files: "Менеджер файлів",
     files: "Менеджер файлів",
     makerworld: "MakerWorld",
     makerworld: "MakerWorld",
+    modelSources: "Джерела моделей",
     notifications: "Сповіщення",
     notifications: "Сповіщення",
     settings: "Налаштування",
     settings: "Налаштування",
     system: "Система",
     system: "Система",
@@ -7656,6 +7657,79 @@ export default {
     noHistory: "Виявлень ще немає.",
     noHistory: "Виявлень ще немає.",
   },
   },
 
 
+  modelSources: {
+    title: "Джерела моделей",
+    tabMakerworld: "MakerWorld",
+    tabManyfold: "Manyfold",
+  },
+  manyfold: {
+    description: "Переглядайте бібліотеку Manyfold, шукайте в ній та імпортуйте файли, щоб нарізати й надрукувати їх.",
+    notConnectedTitle: "Manyfold не підключено",
+    notConnectedBody: "Попросіть адміністратора підключити вашу бібліотеку Manyfold.",
+    connectionTitle: "Підключення до Manyfold",
+    connectionHelp: "У Manyfold відкрийте «Налаштування → API» і створіть застосунок з областями \"public\" і \"read\". Введіть тут його ID клієнта та секрет. Bambuddy бачить моделі, які може бачити власник застосунку.",
+    url: "URL Manyfold",
+    urlPlaceholder: "http://192.168.1.10:3214",
+    clientId: "ID клієнта",
+    clientSecret: "Секрет клієнта",
+    clientSecretStored: "Збережено. Залиште порожнім, щоб зберегти його.",
+    test: "Перевірити підключення",
+    testOk: "Підключено. Моделей видно Bambuddy: {{count}}",
+    disconnect: "Відключити",
+    disconnectTitle: "Відключити Manyfold?",
+    disconnectBody: "Bambuddy забуде URL, ID клієнта та секрет Manyfold. Імпортовані файли залишаться у вашій бібліотеці.",
+    disconnected: "Manyfold відключено",
+    editConnection: "Підключення",
+    searchPlaceholder: "Пошук моделей",
+    modelCount: "Моделей: {{count}}",
+    noModels: "Моделей не знайдено.",
+    previous: "Назад",
+    next: "Далі",
+    page: "Сторінка {{page}}",
+    back: "До списку моделей",
+    openInManyfold: "Відкрити в Manyfold",
+    license: "Ліцензія",
+    files: "Файли",
+    noFiles: "Ця модель не має файлів.",
+    importTo: "Імпортувати до",
+    folderAuto: "Тека «Manyfold»",
+    import: "Імпортувати",
+    importing: "Імпорт…",
+    imported: "{{filename}} імпортовано",
+    alreadyInLibrary: "Уже у вашій бібліотеці",
+    inLibrary: "У бібліотеці",
+    showInLibrary: "Показати в бібліотеці",
+    notImportable: "Bambuddy не може нарізати чи надрукувати файл цього типу",
+    selectAll: "Вибрати все",
+    selectFile: "Вибрати {{name}}",
+    importSelected: "Імпортувати вибране ({{count}})",
+    importAll: "Імпортувати все",
+    importProgress: "Імпорт {{current}}/{{total}}",
+    bulkDone: "Імпортовано: {{imported}} · уже в бібліотеці: {{existing}} · з помилкою: {{failed}}",
+    bulkFailed: "Не імпортовано: {{names}}. {{reason}}",
+    bulkNothing: "Нічого імпортувати: усі файли для друку вже у вашій бібліотеці.",
+    selectModel: "Вибрати {{name}}",
+    selectPage: "Вибрати сторінку",
+    modelsSelected: "Вибрано моделей: {{count}}",
+    importModels: "Імпортувати їхні файли",
+    clearSelection: "Скасувати вибір",
+    collectProgress: "Читання моделей {{current}}/{{total}}",
+    modelsUnreadable: "Не вдалося прочитати: {{names}}",
+    errors: {
+      manyfold_not_configured: "Manyfold не підключено.",
+      manyfold_credentials: "Manyfold не прийняв ID клієнта або секрет.",
+      manyfold_scope: "Застосунку Manyfold потрібна область \"read\". Змініть його в Manyfold у розділі «Налаштування → API».",
+      manyfold_rate_limited: "Manyfold обмежує входи. Спробуйте ще раз за кілька хвилин.",
+      manyfold_unreachable: "Bambuddy не може зв'язатися з Manyfold. Перевірте URL і чи працює Manyfold.",
+      manyfold_forbidden: "Manyfold відмовив у доступі. Перевірте, чи бачить власник застосунку цю модель.",
+      manyfold_not_found: "Manyfold не знаходить цю модель або файл.",
+      manyfold_too_large: "Файл більший за 200 МБ.",
+      manyfold_not_importable: "Імпортувати можна лише файли 3MF, STL і STEP.",
+      manyfold_bad_url: "Введіть URL Manyfold, що починається з http:// або https://.",
+      manyfold_secret_required: "Введіть секрет клієнта.",
+      manyfold_failed: "Manyfold відповів помилкою.",
+    },
+  },
   makerworld: {
   makerworld: {
     title: "MakerWorld",
     title: "MakerWorld",
     description: "Вставте модель MakerWorld URL, щоб імпортувати та роздрукувати її безпосередньо з Bambuddy — не виходячи з програми Bambu Handy.",
     description: "Вставте модель MakerWorld URL, щоб імпортувати та роздрукувати її безпосередньо з Bambuddy — не виходячи з програми Bambu Handy.",

+ 74 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -12,6 +12,7 @@ export default {
     inventory: '耗材',
     inventory: '耗材',
     files: '文件管理器',
     files: '文件管理器',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: '模型来源',
     notifications: '通知',
     notifications: '通知',
     settings: '设置',
     settings: '设置',
     system: '系统',
     system: '系统',
@@ -7599,6 +7600,79 @@ export default {
     historyTitle: '最近检测',
     historyTitle: '最近检测',
     noHistory: '暂无检测记录。',
     noHistory: '暂无检测记录。',
   },
   },
+  modelSources: {
+    title: '模型来源',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: '浏览和搜索你的 Manyfold 模型库,导入其中的文件进行切片和打印。',
+    notConnectedTitle: 'Manyfold 未连接',
+    notConnectedBody: '请让管理员连接你的 Manyfold 模型库。',
+    connectionTitle: 'Manyfold 连接',
+    connectionHelp: '在 Manyfold 中打开“设置 → API”,创建一个带有 "public" 和 "read" 权限范围的应用,并在此填写它的客户端 ID 和密钥。Bambuddy 能看到该应用所有者可见的模型。',
+    url: 'Manyfold 地址',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: '客户端 ID',
+    clientSecret: '客户端密钥',
+    clientSecretStored: '已保存。留空即保留原值。',
+    test: '测试连接',
+    testOk: '已连接。Bambuddy 可见的模型:{{count}}',
+    disconnect: '断开连接',
+    disconnectTitle: '断开 Manyfold?',
+    disconnectBody: 'Bambuddy 将删除 Manyfold 的地址、客户端 ID 和密钥。已导入的文件仍保留在你的库中。',
+    disconnected: '已断开 Manyfold',
+    editConnection: '连接',
+    searchPlaceholder: '搜索模型',
+    modelCount: '模型:{{count}}',
+    noModels: '未找到模型。',
+    previous: '上一页',
+    next: '下一页',
+    page: '第 {{page}} 页',
+    back: '返回模型列表',
+    openInManyfold: '在 Manyfold 中打开',
+    license: '许可证',
+    files: '文件',
+    noFiles: '此模型没有文件。',
+    importTo: '导入到',
+    folderAuto: '“Manyfold”文件夹',
+    import: '导入',
+    importing: '正在导入…',
+    imported: '已导入 {{filename}}',
+    alreadyInLibrary: '已在你的库中',
+    inLibrary: '已在库中',
+    showInLibrary: '在库中显示',
+    notImportable: 'Bambuddy 无法切片或打印此类文件',
+    selectAll: '全选',
+    selectFile: '选择 {{name}}',
+    importSelected: '导入所选({{count}})',
+    importAll: '全部导入',
+    importProgress: '正在导入 {{current}}/{{total}}',
+    bulkDone: '已导入:{{imported}} · 已在库中:{{existing}} · 失败:{{failed}}',
+    bulkFailed: '未导入:{{names}}。{{reason}}',
+    bulkNothing: '没有可导入的文件:所有可打印文件都已在你的库中。',
+    selectModel: '选择 {{name}}',
+    selectPage: '选择本页',
+    modelsSelected: '已选模型:{{count}}',
+    importModels: '导入它们的文件',
+    clearSelection: '清除选择',
+    collectProgress: '正在读取模型 {{current}}/{{total}}',
+    modelsUnreadable: '无法读取:{{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold 未连接。',
+      manyfold_credentials: 'Manyfold 不接受该客户端 ID 或密钥。',
+      manyfold_scope: 'Manyfold 应用需要 "read" 权限范围。请在 Manyfold 的“设置 → API”中修改。',
+      manyfold_rate_limited: 'Manyfold 正在限制登录次数。请几分钟后重试。',
+      manyfold_unreachable: 'Bambuddy 无法连接到 Manyfold。请检查地址以及 Manyfold 是否正在运行。',
+      manyfold_forbidden: 'Manyfold 拒绝访问。请确认该应用的所有者能看到此模型。',
+      manyfold_not_found: 'Manyfold 找不到此模型或文件。',
+      manyfold_too_large: '文件大于 200 MB。',
+      manyfold_not_importable: '只能导入 3MF、STL 和 STEP 文件。',
+      manyfold_bad_url: '请输入以 http:// 或 https:// 开头的 Manyfold 地址。',
+      manyfold_secret_required: '请输入客户端密钥。',
+      manyfold_failed: 'Manyfold 返回了错误。',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: '粘贴 MakerWorld 模型链接,即可直接在 Bambuddy 中导入并打印 —— 无需切换到 Bambu Handy 应用。',
     description: '粘贴 MakerWorld 模型链接,即可直接在 Bambuddy 中导入并打印 —— 无需切换到 Bambu Handy 应用。',

+ 74 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -12,6 +12,7 @@ export default {
     inventory: '耗材',
     inventory: '耗材',
     files: '檔案管理器',
     files: '檔案管理器',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
+    modelSources: '模型來源',
     notifications: '通知',
     notifications: '通知',
     settings: '設定',
     settings: '設定',
     system: '系統',
     system: '系統',
@@ -7599,6 +7600,79 @@ export default {
     historyTitle: '最近檢測',
     historyTitle: '最近檢測',
     noHistory: '尚無檢測紀錄。',
     noHistory: '尚無檢測紀錄。',
   },
   },
+  modelSources: {
+    title: '模型來源',
+    tabMakerworld: 'MakerWorld',
+    tabManyfold: 'Manyfold',
+  },
+  manyfold: {
+    description: '瀏覽和搜尋你的 Manyfold 模型庫,匯入其中的檔案進行切片和列印。',
+    notConnectedTitle: 'Manyfold 未連線',
+    notConnectedBody: '請讓管理員連線你的 Manyfold 模型庫。',
+    connectionTitle: 'Manyfold 連線',
+    connectionHelp: '在 Manyfold 中開啟「設定 → API」,建立一個具有 "public" 和 "read" 權限範圍的應用程式,並在此填寫它的用戶端 ID 和密鑰。Bambuddy 能看到該應用程式擁有者可見的模型。',
+    url: 'Manyfold 網址',
+    urlPlaceholder: 'http://192.168.1.10:3214',
+    clientId: '用戶端 ID',
+    clientSecret: '用戶端密鑰',
+    clientSecretStored: '已儲存。留空即保留原值。',
+    test: '測試連線',
+    testOk: '已連線。Bambuddy 可見的模型:{{count}}',
+    disconnect: '中斷連線',
+    disconnectTitle: '中斷 Manyfold 連線?',
+    disconnectBody: 'Bambuddy 將刪除 Manyfold 的網址、用戶端 ID 和密鑰。已匯入的檔案仍保留在你的庫中。',
+    disconnected: '已中斷 Manyfold 連線',
+    editConnection: '連線',
+    searchPlaceholder: '搜尋模型',
+    modelCount: '模型:{{count}}',
+    noModels: '找不到模型。',
+    previous: '上一頁',
+    next: '下一頁',
+    page: '第 {{page}} 頁',
+    back: '返回模型列表',
+    openInManyfold: '在 Manyfold 中開啟',
+    license: '授權條款',
+    files: '檔案',
+    noFiles: '此模型沒有檔案。',
+    importTo: '匯入到',
+    folderAuto: '「Manyfold」資料夾',
+    import: '匯入',
+    importing: '正在匯入…',
+    imported: '已匯入 {{filename}}',
+    alreadyInLibrary: '已在你的庫中',
+    inLibrary: '已在庫中',
+    showInLibrary: '在庫中顯示',
+    notImportable: 'Bambuddy 無法切片或列印此類檔案',
+    selectAll: '全選',
+    selectFile: '選擇 {{name}}',
+    importSelected: '匯入所選({{count}})',
+    importAll: '全部匯入',
+    importProgress: '正在匯入 {{current}}/{{total}}',
+    bulkDone: '已匯入:{{imported}} · 已在庫中:{{existing}} · 失敗:{{failed}}',
+    bulkFailed: '未匯入:{{names}}。{{reason}}',
+    bulkNothing: '沒有可匯入的檔案:所有可列印檔案都已在你的庫中。',
+    selectModel: '選擇 {{name}}',
+    selectPage: '選擇本頁',
+    modelsSelected: '已選模型:{{count}}',
+    importModels: '匯入它們的檔案',
+    clearSelection: '清除選擇',
+    collectProgress: '正在讀取模型 {{current}}/{{total}}',
+    modelsUnreadable: '無法讀取:{{names}}',
+    errors: {
+      manyfold_not_configured: 'Manyfold 未連線。',
+      manyfold_credentials: 'Manyfold 不接受該用戶端 ID 或密鑰。',
+      manyfold_scope: 'Manyfold 應用程式需要 "read" 權限範圍。請在 Manyfold 的「設定 → API」中修改。',
+      manyfold_rate_limited: 'Manyfold 正在限制登入次數。請幾分鐘後再試。',
+      manyfold_unreachable: 'Bambuddy 無法連線到 Manyfold。請檢查網址以及 Manyfold 是否正在執行。',
+      manyfold_forbidden: 'Manyfold 拒絕存取。請確認該應用程式的擁有者能看到此模型。',
+      manyfold_not_found: 'Manyfold 找不到此模型或檔案。',
+      manyfold_too_large: '檔案大於 200 MB。',
+      manyfold_not_importable: '只能匯入 3MF、STL 和 STEP 檔案。',
+      manyfold_bad_url: '請輸入以 http:// 或 https:// 開頭的 Manyfold 網址。',
+      manyfold_secret_required: '請輸入用戶端密鑰。',
+      manyfold_failed: 'Manyfold 傳回了錯誤。',
+    },
+  },
   makerworld: {
   makerworld: {
     title: 'MakerWorld',
     title: 'MakerWorld',
     description: '貼上 MakerWorld 模型連結,即可直接在 Bambuddy 中匯入並列印 —— 無需切換至 Bambu Handy 應用程式。',
     description: '貼上 MakerWorld 模型連結,即可直接在 Bambuddy 中匯入並列印 —— 無需切換至 Bambu Handy 應用程式。',

+ 17 - 11
frontend/src/pages/MakerworldPage.tsx

@@ -107,7 +107,9 @@ function useElapsedSeconds(active: boolean): number {
   return elapsed;
   return elapsed;
 }
 }
 
 
-export function MakerworldPage() {
+/** ``embedded`` renders it as the MakerWorld tab of the Model Sources page,
+ *  which brings its own page title. */
+export function MakerworldPage({ embedded = false }: { embedded?: boolean } = {}) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { hasPermission } = useAuth();
   const { hasPermission } = useAuth();
   const { showToast } = useToast();
   const { showToast } = useToast();
@@ -419,16 +421,20 @@ export function MakerworldPage() {
   const downloadCount = pickNumber(design, 'downloadCount');
   const downloadCount = pickNumber(design, 'downloadCount');
 
 
   return (
   return (
-    <div className="p-4 md:p-8 max-w-screen-2xl space-y-6">
-      <div>
-        <h1 className="text-2xl font-bold text-white flex items-center gap-3">
-          <Globe className="w-7 h-7 text-bambu-green" />
-          {t('makerworld.title')}
-        </h1>
-        <p className="text-bambu-gray mt-1">
-          {t('makerworld.description')}
-        </p>
-      </div>
+    <div className={embedded ? 'space-y-6' : 'p-4 md:p-8 max-w-screen-2xl space-y-6'}>
+      {embedded ? (
+        <p className="text-bambu-gray">{t('makerworld.description')}</p>
+      ) : (
+        <div>
+          <h1 className="text-2xl font-bold text-white flex items-center gap-3">
+            <Globe className="w-7 h-7 text-bambu-green" />
+            {t('makerworld.title')}
+          </h1>
+          <p className="text-bambu-gray mt-1">
+            {t('makerworld.description')}
+          </p>
+        </div>
+      )}
 
 
       {/* Two-column layout: main flow on the left, sticky "Recent imports"
       {/* Two-column layout: main flow on the left, sticky "Recent imports"
           sidebar on the right at lg+. Collapses to single column on narrow
           sidebar on the right at lg+. Collapses to single column on narrow

+ 78 - 0
frontend/src/pages/ModelSourcesPage.tsx

@@ -0,0 +1,78 @@
+import { useSearchParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Globe, Library } from 'lucide-react';
+
+import { api } from '../api/client';
+import { ManyfoldTab } from '../components/ManyfoldTab';
+import { useAuth } from '../contexts/AuthContext';
+import { MakerworldPage } from './MakerworldPage';
+
+type SourceTab = 'makerworld' | 'manyfold';
+
+/**
+ * Model Sources (#1471): the places models come into the library from, one tab
+ * each. MakerWorld takes pasted links; Manyfold is a self-hosted library that
+ * is browsed. Each tab shows only to users with that source's permission, and
+ * Manyfold only once it is connected, except to those who can connect it.
+ */
+export function ModelSourcesPage() {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const canViewManyfold = hasPermission('manyfold:view');
+  const manyfoldStatus = useQuery({
+    queryKey: ['manyfold-status'],
+    queryFn: () => api.getManyfoldStatus(),
+    enabled: canViewManyfold,
+  });
+  const showManyfold =
+    canViewManyfold && (manyfoldStatus.data?.configured === true || hasPermission('settings:update'));
+
+  const tabs: { id: SourceTab; label: string; icon: typeof Globe }[] = [];
+  if (hasPermission('makerworld:view')) tabs.push({ id: 'makerworld', label: t('modelSources.tabMakerworld'), icon: Globe });
+  if (showManyfold) tabs.push({ id: 'manyfold', label: t('modelSources.tabManyfold'), icon: Library });
+
+  const requested = searchParams.get('tab');
+  // A Manyfold-only user, before Manyfold is connected, has no tab; show them
+  // the Manyfold tab's "not connected" note rather than an empty page.
+  const active =
+    tabs.find((tab) => tab.id === requested)?.id ?? tabs[0]?.id ?? (canViewManyfold ? 'manyfold' : undefined);
+
+  return (
+    <div className="p-4 md:p-8 max-w-screen-2xl">
+      <div className="mb-6">
+        <h1 className="text-2xl font-bold text-white flex items-center gap-3">
+          <Globe className="w-7 h-7 text-bambu-green" />
+          {t('modelSources.title')}
+        </h1>
+      </div>
+
+      {tabs.length > 1 && (
+        <div className="flex border-b border-bambu-dark-tertiary mb-6" role="tablist">
+          {tabs.map(({ id, label, icon: Icon }) => (
+            <button
+              key={id}
+              type="button"
+              role="tab"
+              aria-selected={active === id}
+              onClick={() => setSearchParams({ tab: id }, { replace: true })}
+              className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
+                active === id
+                  ? 'text-bambu-green border-bambu-green'
+                  : 'text-bambu-gray hover:text-white border-transparent'
+              }`}
+            >
+              <Icon className="w-4 h-4" />
+              {label}
+            </button>
+          ))}
+        </div>
+      )}
+
+      {active === 'makerworld' && <MakerworldPage embedded />}
+      {active === 'manyfold' && <ManyfoldTab />}
+    </div>
+  );
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-DFKL6Wuy.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-DWMSVJDW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-6L0JEhlx.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-C5OR618T.css


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-D1FhwA-P.css


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-D2LwYQoy.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-CzFKNr8-.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Cp5vuzkv.js"></script>
+    <script type="module" crossorigin src="/assets/index-D2LwYQoy.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-D1FhwA-P.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-C5OR618T.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff