maziggy hai 2 días
pai
achega
926957f648

+ 1 - 0
CHANGELOG.md

@@ -47,6 +47,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **Generate Thumbnails works for users who can edit only their own files, and external folder scans no longer wait on PDF thumbnails** — The toolbar's **Generate Thumbnails** button and the file menu's **Generate Thumbnail** entry were offered to users who may edit only their own library files, but the server refused them. For those users both now cover the files they uploaded themselves; users who may edit all files still get everyone's. Scanning an external folder rendered each new PDF's thumbnail before the scan finished, which on a Raspberry Pi with a share full of PDFs kept the scan waiting for a long time. PDF thumbnails are now made in the background after the scan, like STL thumbnails already were.
 - **The Low Filament notification now fires (#2913, reported and contributed by @ojimpo in #2940)** — The event had a toggle on every notification provider, but nothing in Bambuddy ever sent it, so switching it on did nothing. It now alerts when a spool assigned to an AMS, AMS-HT or external slot drops below its low-stock threshold. That is the same global percentage (default 20 %) and per-spool override that drive the Low Stock count on the Inventory page, so the alert and the count agree. Remaining filament comes from the spool's weight, not the AMS remain percentage, which can be far off. It works with the built-in inventory and with Spoolman; with Spoolman only the global threshold applies. Each spool alerts once per slot and can alert again after it goes back above the threshold, for example when a fresh one is loaded. Archived spools and slots with no assigned spool never alert. The check runs every 30 seconds and does nothing unless a provider has the event switched on. `{color}` is now filled in for custom templates. If you switched Low Filament on in the past, expect one alert for each assigned spool that is already low after updating, and again after each restart while it stays low.
 - **The Low Filament notification now fires (#2913, reported and contributed by @ojimpo in #2940)** — The event had a toggle on every notification provider, but nothing in Bambuddy ever sent it, so switching it on did nothing. It now alerts when a spool assigned to an AMS, AMS-HT or external slot drops below its low-stock threshold. That is the same global percentage (default 20 %) and per-spool override that drive the Low Stock count on the Inventory page, so the alert and the count agree. Remaining filament comes from the spool's weight, not the AMS remain percentage, which can be far off. It works with the built-in inventory and with Spoolman; with Spoolman only the global threshold applies. Each spool alerts once per slot and can alert again after it goes back above the threshold, for example when a fresh one is loaded. Archived spools and slots with no assigned spool never alert. The check runs every 30 seconds and does nothing unless a provider has the event switched on. `{color}` is now filled in for custom templates. If you switched Low Filament on in the past, expect one alert for each assigned spool that is already low after updating, and again after each restart while it stays low.
 - **A live camera view no longer stops for good after about half an hour on X1, H2 and P2 printers** — These printers' camera streams come over RTSP, and the printer ends each session after a while; a stock X1 Carbon ends every one after exactly a minute. Bambuddy reconnects straight away, so viewers never notice, but every reconnect counted against a limit of 30 for the life of the stream. Half an hour into a print the camera stopped and did not come back until the page was reopened. The limit now counts failed attempts in a row, and a session that delivered video resets it. A printer that refuses the camera for a while, for example because another app is on it, used to be given up on after 30 attempts in nine seconds. Bambuddy now waits longer between failed attempts, up to five seconds each, so it gets about two minutes. External cameras had the same problem with a limit of three: a camera server that ends its sessions now and then stopped the stream on the fourth drop. External streams now always reconnect after a session that delivered video. Found while investigating #3189.
 - **A live camera view no longer stops for good after about half an hour on X1, H2 and P2 printers** — These printers' camera streams come over RTSP, and the printer ends each session after a while; a stock X1 Carbon ends every one after exactly a minute. Bambuddy reconnects straight away, so viewers never notice, but every reconnect counted against a limit of 30 for the life of the stream. Half an hour into a print the camera stopped and did not come back until the page was reopened. The limit now counts failed attempts in a row, and a session that delivered video resets it. A printer that refuses the camera for a while, for example because another app is on it, used to be given up on after 30 attempts in nine seconds. Bambuddy now waits longer between failed attempts, up to five seconds each, so it gets about two minutes. External cameras had the same problem with a limit of three: a camera server that ends its sessions now and then stopped the stream on the fourth drop. External streams now always reconnect after a session that delivered video. Found while investigating #3189.
 - **A slot that reads empty for a moment no longer loses its spool assignment (#3186, reported by @Sawtaytoes)** — An idle X1 Carbon sent one status update that showed a whole AMS unit as empty, with no colour or material in any slot, and Bambuddy deleted all four spool assignments on it at once. The spools never moved, and the next update reported them again. Nothing brought the assignments back, and for a non-RFID spool the assignment is the only record of which spool is in the slot. The #3100 fix covered a blank slot the AMS still reported as occupied, but not one briefly reported as empty. A slot that looks empty, or that drops out of the AMS data, now keeps its assignment for two minutes and loses it only if it is still empty then. Bambuddy checks again by itself when the two minutes are up, so a spool that was really taken out does not wait for the next AMS change. A different spool the AMS can identify, by its RFID tag or its colour and material, still releases the old assignment immediately. A spool it cannot read that goes in during those two minutes releases it once they are up, rather than inheriting the old spool's assignment. Assigning a spool to the slot yourself cancels the wait. Spoolman mode's slot links follow the same rules.
 - **A slot that reads empty for a moment no longer loses its spool assignment (#3186, reported by @Sawtaytoes)** — An idle X1 Carbon sent one status update that showed a whole AMS unit as empty, with no colour or material in any slot, and Bambuddy deleted all four spool assignments on it at once. The spools never moved, and the next update reported them again. Nothing brought the assignments back, and for a non-RFID spool the assignment is the only record of which spool is in the slot. The #3100 fix covered a blank slot the AMS still reported as occupied, but not one briefly reported as empty. A slot that looks empty, or that drops out of the AMS data, now keeps its assignment for two minutes and loses it only if it is still empty then. Bambuddy checks again by itself when the two minutes are up, so a spool that was really taken out does not wait for the next AMS change. A different spool the AMS can identify, by its RFID tag or its colour and material, still releases the old assignment immediately. A spool it cannot read that goes in during those two minutes releases it once they are up, rather than inheriting the old spool's assignment. Assigning a spool to the slot yourself cancels the wait. Spoolman mode's slot links follow the same rules.

+ 42 - 28
backend/app/api/routes/library.py

@@ -846,8 +846,8 @@ MAX_CLIENT_THUMBNAIL_EDGE = 2048
 STORED_CLIENT_THUMBNAIL_EDGE = 512
 STORED_CLIENT_THUMBNAIL_EDGE = 512
 
 
 
 
-async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
-    """Generate STL thumbnails for an external folder tree in the background.
+async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
+    """Generate STL and PDF thumbnails for an external folder tree in the background.
 
 
     Spawned via ``asyncio.create_task`` from ``scan_external_folder`` so the
     Spawned via ``asyncio.create_task`` from ``scan_external_folder`` so the
     HTTP request can return as soon as the filesystem walk + folder/file rows
     HTTP request can return as soon as the filesystem walk + folder/file rows
@@ -855,7 +855,9 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
     the request open for many minutes (each file triggers a ``trimesh.load``
     the request open for many minutes (each file triggers a ``trimesh.load``
     + matplotlib render, ~1-5s each) and the FE modal times out before the
     + matplotlib render, ~1-5s each) and the FE modal times out before the
     final ``db.commit()`` runs — causing the original symptom in #1299 where
     final ``db.commit()`` runs — causing the original symptom in #1299 where
-    subdirectories never showed up because nothing got committed.
+    subdirectories never showed up because nothing got committed. PDFs are
+    faster (a PDFium page render) but a share holding hundreds of them would
+    still hold the request open, so they are rendered here too.
 
 
     Opens its own session because the request session is closed by the time
     Opens its own session because the request session is closed by the time
     this task starts running. Commits per-file so a worker restart mid-run
     this task starts running. Commits per-file so a worker restart mid-run
@@ -869,22 +871,30 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
         result = await db.execute(
         result = await db.execute(
             LibraryFile.active().where(
             LibraryFile.active().where(
                 LibraryFile.folder_id.in_(folder_ids),
                 LibraryFile.folder_id.in_(folder_ids),
-                LibraryFile.file_type == "stl",
+                LibraryFile.file_type.in_(("stl", "pdf")),
                 LibraryFile.thumbnail_path.is_(None),
                 LibraryFile.thumbnail_path.is_(None),
             )
             )
         )
         )
-        stl_files = result.scalars().all()
-        if not stl_files:
+        target_files = result.scalars().all()
+        if not target_files:
             return
             return
         logger.info(
         logger.info(
-            "Backfilling STL thumbnails: %d file(s) across %d folder(s)",
-            len(stl_files),
+            "Backfilling STL/PDF thumbnails: %d file(s) across %d folder(s)",
+            len(target_files),
             len(folder_ids),
             len(folder_ids),
         )
         )
-        for stl_file in stl_files:
-            abs_path = to_absolute_path(stl_file.file_path)
+        for target_file in target_files:
+            abs_path = to_absolute_path(target_file.file_path)
             if not abs_path or not abs_path.exists():
             if not abs_path or not abs_path.exists():
                 continue
                 continue
+            if target_file.file_type == "pdf":
+                # generate_pdf_thumbnail never raises; an unreadable PDF
+                # returns None and keeps the browser-preview fallback.
+                thumb_path = await asyncio.to_thread(generate_pdf_thumbnail, abs_path, thumbnails_dir)
+                if thumb_path:
+                    target_file.thumbnail_path = to_relative_path(Path(thumb_path))
+                    await db.commit()
+                continue
             # Pre-skip files too small to contain even a single triangle.
             # Pre-skip files too small to contain even a single triangle.
             # Bulk-uploaded ZIPs of stub STLs would otherwise trigger one
             # Bulk-uploaded ZIPs of stub STLs would otherwise trigger one
             # trimesh.load() call + one debug log line per stub.
             # trimesh.load() call + one debug log line per stub.
@@ -899,7 +909,7 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
                 logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
                 logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
                 continue
                 continue
             if thumb_path:
             if thumb_path:
-                stl_file.thumbnail_path = to_relative_path(Path(thumb_path))
+                target_file.thumbnail_path = to_relative_path(Path(thumb_path))
                 await db.commit()
                 await db.commit()
 
 
 
 
@@ -1999,8 +2009,8 @@ async def scan_external_folder(
                 except Exception as e:
                 except Exception as e:
                     logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
                     logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
 
 
-            # STL thumbnails are deferred to a background task spawned after
-            # the scan's db.commit() — see _backfill_external_stl_thumbnails.
+            # STL and PDF thumbnails are deferred to a background task spawned
+            # after the scan's db.commit() — see _backfill_external_thumbnails.
             # Doing them inline would block the HTTP request for minutes on a
             # Doing them inline would block the HTTP request for minutes on a
             # large NAS mount (#1299).
             # large NAS mount (#1299).
 
 
@@ -2020,14 +2030,6 @@ async def scan_external_folder(
                 if thumbnail_path_str:
                 if thumbnail_path_str:
                     thumbnail_path = to_relative_path(Path(thumbnail_path_str))
                     thumbnail_path = to_relative_path(Path(thumbnail_path_str))
 
 
-            # Render page one of a PDF so it has a thumbnail before anyone opens it
-            if file_type == "pdf" and thumbnail_path is None:
-                thumbnail_path_str = await asyncio.to_thread(
-                    generate_pdf_thumbnail, filepath, get_library_thumbnails_dir()
-                )
-                if thumbnail_path_str:
-                    thumbnail_path = to_relative_path(Path(thumbnail_path_str))
-
             db_file = LibraryFile(
             db_file = LibraryFile(
                 folder_id=target_folder_id,
                 folder_id=target_folder_id,
                 is_external=True,
                 is_external=True,
@@ -2106,17 +2108,17 @@ async def scan_external_folder(
 
 
     await db.commit()
     await db.commit()
 
 
-    # Spawn STL thumbnail backfill in the background — the scan endpoint
+    # Spawn STL/PDF thumbnail backfill in the background — the scan endpoint
     # returns immediately so the FE modal closes and subdirectories are
     # returns immediately so the FE modal closes and subdirectories are
     # visible right away; thumbnails fill in over the following seconds /
     # visible right away; thumbnails fill in over the following seconds /
-    # minutes as the task processes each STL file. Survives FE refresh —
+    # minutes as the task processes each file. Survives FE refresh —
     # the task lives in the FastAPI event loop, not the request scope.
     # the task lives in the FastAPI event loop, not the request scope.
     # folder_cache.values() covers the root + every pre-existing subfolder
     # folder_cache.values() covers the root + every pre-existing subfolder
     # + every subfolder created during this scan. all_folder_ids on its own
     # + every subfolder created during this scan. all_folder_ids on its own
     # would miss the newly-created ones (it's snapshotted before the walk).
     # would miss the newly-created ones (it's snapshotted before the walk).
     spawn_background_task(
     spawn_background_task(
-        _backfill_external_stl_thumbnails(list(set(folder_cache.values()))),
-        name=f"stl-backfill-folder-{folder_id}",
+        _backfill_external_thumbnails(list(set(folder_cache.values()))),
+        name=f"thumbnail-backfill-folder-{folder_id}",
     )
     )
 
 
     return {"status": "success", "added": added, "removed": removed}
     return {"status": "success", "added": added, "removed": removed}
@@ -2772,12 +2774,20 @@ async def extract_zip_file(
 async def batch_generate_stl_thumbnails(
 async def batch_generate_stl_thumbnails(
     request: BatchThumbnailRequest,
     request: BatchThumbnailRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
 ):
 ):
     """Generate thumbnails for STL and PDF files in batch.
     """Generate thumbnails for STL and PDF files in batch.
 
 
-    Note: Requires library:update_all permission since this is a batch operation
-    that may affect files owned by different users.
+    With library:update_all this covers every matching file; with only
+    library:update_own it is narrowed to the caller's own files, the same
+    rule as update_file. The File Manager offers the toolbar button and the
+    per-file "Generate Thumbnail" entry to update_own users, and both land
+    here.
 
 
     PDFs are included so the ones added before server-side PDF thumbnails
     PDFs are included so the ones added before server-side PDF thumbnails
     existed can be backfilled without opening each preview. The route keeps
     existed can be backfilled without opening each preview. The route keeps
@@ -2794,6 +2804,10 @@ async def batch_generate_stl_thumbnails(
     # Build query based on request
     # Build query based on request
     query = LibraryFile.active().where(LibraryFile.file_type.in_(("stl", "pdf")))
     query = LibraryFile.active().where(LibraryFile.file_type.in_(("stl", "pdf")))
 
 
+    user, can_modify_all = auth_result
+    if not can_modify_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+
     if request.file_ids:
     if request.file_ids:
         # Specific files
         # Specific files
         query = query.where(LibraryFile.id.in_(request.file_ids))
         query = query.where(LibraryFile.id.in_(request.file_ids))

+ 104 - 1
backend/tests/integration/test_library_pdf_thumbnail.py

@@ -3,7 +3,8 @@
 A PDF gets its grid thumbnail when it enters the library - upload, ZIP
 A PDF gets its grid thumbnail when it enters the library - upload, ZIP
 extraction, external-folder scan - instead of only after somebody has opened
 extraction, external-folder scan - instead of only after somebody has opened
 the browser preview. The "Generate Thumbnails" batch backfills PDFs added
 the browser preview. The "Generate Thumbnails" batch backfills PDFs added
-before that.
+before that. An external scan renders them in its background backfill, not
+inside the scan request.
 """
 """
 
 
 import io
 import io
@@ -15,9 +16,12 @@ from PIL import Image
 from reportlab.lib.pagesizes import A4
 from reportlab.lib.pagesizes import A4
 from reportlab.pdfgen import canvas
 from reportlab.pdfgen import canvas
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
 
 
+from backend.app.api.routes import library as library_routes
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
+from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
 
 
 
 
 def _pdf_bytes() -> bytes:
 def _pdf_bytes() -> bytes:
@@ -106,6 +110,17 @@ async def test_external_scan_renders_pdf_thumbnail(
     share.mkdir()
     share.mkdir()
     (share / "manual.pdf").write_bytes(_pdf_bytes())
     (share / "manual.pdf").write_bytes(_pdf_bytes())
 
 
+    # The scan hands thumbnails to a background task. Capture it instead of
+    # letting it run on its own, and give it the test database: the route
+    # module holds its own reference to the real async_session.
+    spawned = []
+    monkeypatch.setattr(library_routes, "spawn_background_task", lambda coro, **_: spawned.append(coro))
+    monkeypatch.setattr(
+        library_routes,
+        "async_session",
+        async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False),
+    )
+
     created = await async_client.post(
     created = await async_client.post(
         "/api/v1/library/folders/external",
         "/api/v1/library/folders/external",
         json={"name": "NAS", "external_path": str(share), "readonly": True, "show_hidden": False},
         json={"name": "NAS", "external_path": str(share), "readonly": True, "show_hidden": False},
@@ -118,6 +133,13 @@ async def test_external_scan_renders_pdf_thumbnail(
     files = (await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")).json()
     files = (await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")).json()
     pdf = next(f for f in files if f["filename"] == "manual.pdf")
     pdf = next(f for f in files if f["filename"] == "manual.pdf")
     row = await db_session.get(LibraryFile, pdf["id"])
     row = await db_session.get(LibraryFile, pdf["id"])
+    # The scan request itself leaves the rendering to the backfill...
+    assert row.thumbnail_path is None
+    assert len(spawned) == 1
+
+    # ...which fills it in.
+    await spawned[0]
+    await db_session.refresh(row)
     _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
     _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
 
 
 
 
@@ -155,3 +177,84 @@ async def test_batch_generate_backfills_pdf_without_thumbnail(async_client: Asyn
     await db_session.refresh(sheet)
     await db_session.refresh(sheet)
     _assert_png_thumbnail(isolated_storage, old.thumbnail_path)
     _assert_png_thumbnail(isolated_storage, old.thumbnail_path)
     assert sheet.thumbnail_path is None
     assert sheet.thumbnail_path is None
+
+
+class TestBatchThumbnailOwnership(TestOwnershipPermissionsSetup):
+    """Operators hold library:update_own only. The File Manager offers them the
+    toolbar button and the per-file "Generate Thumbnail" entry, so the batch
+    route must serve them - narrowed to their own files."""
+
+    @staticmethod
+    async def _pdf_row(db_session, base_dir, name, owner_id):
+        pdf_path = base_dir / "archive" / "library" / "files" / name
+        pdf_path.parent.mkdir(parents=True, exist_ok=True)
+        pdf_path.write_bytes(_pdf_bytes())
+        row = LibraryFile(
+            filename=name,
+            file_path=f"archive/library/files/{name}",
+            file_type="pdf",
+            file_size=pdf_path.stat().st_size,
+            created_by_id=owner_id,
+        )
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+        return row
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_all_missing_covers_only_their_own_files(
+        self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
+    ):
+        mine = await self._pdf_row(db_session, isolated_storage, "mine.pdf", auth_setup["operator_user"]["id"])
+        theirs = await self._pdf_row(db_session, isolated_storage, "theirs.pdf", auth_setup["operator2_user"]["id"])
+        ownerless = await self._pdf_row(db_session, isolated_storage, "ownerless.pdf", None)
+
+        response = await async_client.post(
+            "/api/v1/library/generate-stl-thumbnails",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"all_missing": True},
+        )
+
+        assert response.status_code == 200, response.text
+        assert [r["file_id"] for r in response.json()["results"]] == [mine.id]
+        for row in (mine, theirs, ownerless):
+            await db_session.refresh(row)
+        _assert_png_thumbnail(isolated_storage, mine.thumbnail_path)
+        assert theirs.thumbnail_path is None
+        assert ownerless.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_generate_for_someone_elses_file(
+        self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
+    ):
+        theirs = await self._pdf_row(db_session, isolated_storage, "theirs.pdf", auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            "/api/v1/library/generate-stl-thumbnails",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [theirs.id]},
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["processed"] == 0
+        await db_session.refresh(theirs)
+        assert theirs.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_all_missing_covers_everyone(
+        self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
+    ):
+        mine = await self._pdf_row(db_session, isolated_storage, "mine.pdf", auth_setup["operator_user"]["id"])
+        ownerless = await self._pdf_row(db_session, isolated_storage, "ownerless.pdf", None)
+
+        response = await async_client.post(
+            "/api/v1/library/generate-stl-thumbnails",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={"all_missing": True},
+        )
+
+        assert response.status_code == 200, response.text
+        assert sorted(r["file_id"] for r in response.json()["results"]) == sorted([mine.id, ownerless.id])