Przeglądaj źródła

fix(library): stop reporting success for a bulk add that queued nothing (issue #3112)

POST /library/files/add-to-queue reported every per-file rejection in an
errors array and returned 200 regardless. A caller that checks the status
code saw a successful request, no visible failure, and no queue item.
That is a 400 now when nothing at all was added, with the same reasons in
the body. A call that created some items still succeeds, because it did.

The items it created were aimed at nothing. The route always wrote
printer_id=None with no target_model, and the scheduler dispatches on one
or the other -- so those rows matched neither branch and could never be
picked up by anything. They sat in Unassigned until someone opened each
one by hand.

The request takes an optional printer_id or target_model for the batch,
and with neither it aims each file at the model its own G-code declares.
Only when a printer of that model is active: owning no H2D is the user's
situation rather than their mistake, so the file still queues as the
unassigned row it has always been, rather than gaining a target nothing
can answer.

Three gates POST /queue/ has applied for a while now apply here too,
because an item reaching the scheduler through this route has to be as
printable as one reaching it through that one: the cross-model check that
stops a file sliced for one printer being dispatched to another (#2578),
the filename check that would otherwise surface as a failed upload hours
later (#1540), and the filament requirements the scheduler matches before
handing a model-based item to hardware.

Nothing inside Bambuddy calls this endpoint -- the Library's own Print
action goes through the queue API with a printer already chosen -- which
is how it came to drift this far from it.

-----

fix(library): scope add-to-queue file reads to the caller

The bulk add resolved its files by raw id. Every other read in this
module goes through the ownership gate, and so does the single-item
queue path; this one did not.

Invisible rows are dropped before the loop, so they report as the plain
"File not found" an unknown id already gets.
maziggy 13 godzin temu
rodzic
commit
70b42d1c8d

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **The Library bulk add-to-queue answered 200 for a call that queued nothing, and queued items nothing could dispatch (#3112, reported by @toxxicpickles)** — `POST /api/v1/library/files/add-to-queue` reported every per-file rejection in an `errors` array and returned `200` regardless, so a caller that checked the status code saw a successful request, no visible failure, and no queue item. That is now a `400` when nothing at all was added, with the same per-file reasons in the body; a call that created some items still succeeds, because it did. The items it created were also aimed at nothing: the route always wrote them with no printer and no target model, and the scheduler dispatches on one or the other, so those rows could never be picked up by anything and sat in Unassigned until someone opened each one and assigned it by hand. The request now takes an optional `printer_id` or `target_model` for the batch, and with neither it aims each file at the model its own G-code declares — when a printer of that model is active, since aiming a job at hardware nobody owns would only look like progress. Three gates `POST /api/v1/queue/` has applied for a while now apply here too, because an item reaching the scheduler through this route has to be as printable as one reaching it through that one: the cross-model check that stops a file sliced for one printer being dispatched to another, the filename check that would otherwise surface as a failed upload hours later, and the filament requirements the scheduler matches before handing a model-based item to hardware. This endpoint has no caller inside Bambuddy — the Library's own Print action goes through the queue API with a printer already chosen — which is how it came to drift this far from it.
 - **The connection diagnostic told anyone whose LAN was not a /24 that their printer was on a different network (#3092, reported by @cwawak)** — the reporter's LAN is `192.168.96.0/22`, his Bambuddy sits at `192.168.96.9` and his printer at `192.168.98.170`, four hundred addresses inside the same subnet. The *Network subnet* check warned that the two were on different networks and told him to go configure routing between two halves of one LAN. An IPv4 address does not carry its prefix, and the check supplied `/24` for both sides — the most common LAN, and not the only one. It now reads the prefix off the interface that owns the address, so a `/22`, a `/16` and a `/25` are each read as written, and when no local interface claims the address the check is skipped rather than asserting a split it cannot see. The same check also picked which of Bambuddy's own addresses to compare by probing a route toward `10.255.255.255`, which on any host with more than one network card is not the card the printer is on; it now asks for the route toward the printer itself. On a two-NIC host that alone was enough to warn about a printer sitting on the second card's own subnet.
 - **Podman and LXC installs were told they were not running in a container at all (#3092, reported by @cwawak)** — the *Docker network mode* check said "Not running in Docker — not applicable" to a Bambuddy inside a Podman container, which reads as "you are on bare metal" and sends people looking for the problem somewhere else. Podman runs Bambuddy in exactly the same two shapes Docker does, and the shape is what breaks printer discovery and the Virtual Printer. The check is now **Container network mode**: it names the engine — Docker, Podman, Kubernetes, containerd — and reports host or bridge networking for each of them. Detection of the mode itself gained the general form of the signal it always used: a container that can see an interface created in its own network namespace, rather than only one end of a veth pair, is sharing the host's. A container whose mode genuinely cannot be read now says so and suggests host networking if discovery is failing, instead of guessing "bridge" and telling a perfectly healthy install to recreate itself. An LXC or LXD system container is named too, and told that the question does not apply to it — it sits on the LAN like a small virtual machine, so there is no network mode to recommend. The support bundle now carries the engine name beside the Docker flag for the same reason. Discovery's own Docker detection is deliberately untouched: it switches the Add Printer flow from SSDP to subnet scanning, and SSDP works for a host-networked Podman container.
 - **SpoolBuddy said "Unknown color" for spools Bambuddy names perfectly well (#3090, reported by @Sawtaytoes)** — the reporter scanned a Bambu Lab PLA Silk+ spool that his inventory card calls Candy Red and the kiosk showed the right red swatch above the words "Unknown color". The name was never in the spool record: Bambu's RFID tags often carry none, so Bambuddy has always resolved the swatch's own hex against the colour catalog instead — and the kiosk was printing the empty column. Every screen in SpoolBuddy that shows a colour now resolves it the same way the rest of Bambuddy does, which also stops the ones that had a name on file from showing Bambu's internal code ("A06-D0") in place of it. Searching the inventory by a colour you can read on screen finds it now, in the kiosk and in Bambuddy, instead of matching only what was stored. On Spoolman-backed inventory the catalog also wins over the spool's subtype, which Spoolman installs were being shown as a colour name because Spoolman has no field for one — so those spools said "Silk+" where they now say Candy Red. The label the kiosk falls back to when nothing can name a colour is translated in all 14 languages rather than being English for everyone.

+ 123 - 1
backend/app/api/routes/library.py

@@ -1,5 +1,6 @@
 """API routes for File Manager (Library) functionality."""
 
+import asyncio
 import base64
 import binascii
 import contextlib
@@ -21,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
+from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
+from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
 from backend.app.core.auth import (
     require_media_token_ownership,
     require_ownership_permission,
@@ -33,6 +36,7 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.schemas.library import (
@@ -86,6 +90,7 @@ from backend.app.utils.filename import (
     safe_path_component,
     validate_print_filename,
 )
+from backend.app.utils.printer_models import is_gcode_compatible
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import (
     carries_gcode,
@@ -2833,14 +2838,61 @@ async def add_files_to_queue(
 
     Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
     The archive will be created automatically when the print starts.
+
+    A caller may name a printer or a target model for the whole batch; with
+    neither, each file is aimed at the model it says it was sliced for. The
+    gates are the ones ``POST /queue/`` applies to a single item, because an
+    item that reaches the scheduler through this route has to be as printable
+    as one that reaches it through that one (#3112).
     """
     added: list[AddToQueueResult] = []
     errors: list[AddToQueueError] = []
 
+    # Batch-level targeting. Rejected outright rather than per file: the whole
+    # request names one destination, so a bad one is not a property of any
+    # single file and reporting it fourteen times would say nothing extra.
+    target_model_norm = normalize_model_name(request.target_model)
+    if request.printer_id is not None and target_model_norm:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
+    if request.printer_id is not None:
+        printer_row = (await db.execute(select(Printer).where(Printer.id == request.printer_id))).scalar_one_or_none()
+        if not printer_row:
+            raise HTTPException(400, "Printer not found")
+
+    # Active printers of every model, read once, and only when the batch has no
+    # printer of its own -- with one named, neither the check below nor the
+    # inference in the loop consults it. The explicit target is validated for
+    # the same reason POST /queue/ validates: a model nobody owns is a queue
+    # item that waits forever. The inferred target reads the same set and
+    # silently declines when it finds nothing, because there, owning no such
+    # printer is the user's situation rather than their mistake -- the file
+    # still queues, as the unassigned row it has always been.
+    active_models: set[str] = set()
+    if request.printer_id is None:
+        active_models = {
+            model
+            for (model,) in (
+                await db.execute(select(Printer.model).where(Printer.is_active == True).distinct())  # noqa: E712
+            ).all()
+            if model
+        }
+        if target_model_norm and target_model_norm not in active_models:
+            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+
     # Get all requested files
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     files = {f.id: f for f in result.scalars().all()}
 
+    # Ownership-scoped reads apply here as everywhere else in this module: a
+    # file the caller may not read is a file they may not print. Dropped from
+    # the map rather than refused by name, so the per-file error below is the
+    # same "File not found" an unknown id gets and the response says nothing
+    # about which ids exist. Ownerless rows need LIBRARY_READ_ALL, matching
+    # _ensure_library_file_visible.
+    if current_user is not None and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value):
+        files = {fid: f for fid, f in files.items() if f.created_by_id == current_user.id}
+
     # Project attribution (#1897): a file queued from a project-linked folder
     # inherits that project, so the resulting archive counts toward the
     # project's progress. A file's own project link wins over its folder's.
@@ -2884,10 +2936,66 @@ async def add_files_to_queue(
                 )
                 continue
 
+            # The Bambu SD card is FAT32/exFAT, so an illegal character 553s at
+            # upload time. POST /queue/ rejects those at queue time (#1540) and
+            # this route did not, which turned a nameable mistake into a print
+            # that failed hours later.
+            try:
+                validate_print_filename(lib_file.filename)
+            except InvalidFilenameError as e:
+                errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
+                continue
+
+            # Where this file is aimed. An explicit printer wins; an explicit
+            # model applies to every file and has to be one this file can
+            # legally run on; with neither, the file's own declaration is used
+            # when some active printer answers to it.
+            item_printer_id = request.printer_id
+            item_target_model: str | None = None
+            if item_printer_id is None:
+                if target_model_norm:
+                    sliced_for = (lib_file.file_metadata or {}).get("sliced_for_model")
+                    if not is_gcode_compatible(sliced_for, target_model_norm):
+                        errors.append(
+                            AddToQueueError(
+                                file_id=file_id,
+                                filename=lib_file.filename,
+                                error=(
+                                    f"File was sliced for {sliced_for} and cannot be dispatched to "
+                                    f"{target_model_norm} printers"
+                                ),
+                            )
+                        )
+                        continue
+                    item_target_model = target_model_norm
+                else:
+                    inferred = resolve_variant_model(lib_file)
+                    item_target_model = inferred if inferred in active_models else None
+
+            # Filament the scheduler must match before handing a model-based
+            # item to hardware. Without it the item goes to whichever printer
+            # of that model is idle, whatever is loaded in it.
+            required_filament_types = None
+            if item_target_model:
+                # POST /queue/'s own extractor, borrowed rather than
+                # reimplemented: a second copy of this rule is a second thing
+                # to keep in step.
+                #
+                # Off the loop, unlike there: that route parses one 3MF per
+                # request and this one parses every file in the batch, so on a
+                # bulk add of a few hundred -- especially from an external
+                # folder on a NAS -- the zip reads add up to a stall the whole
+                # event loop takes, status ingest included.
+                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path)
+                if filament_types:
+                    required_filament_types = json.dumps(filament_types)
+
             # Create queue item referencing library file (archive created at print start)
             max_position += 1
             queue_item = PrintQueueItem(
-                printer_id=None,  # Unassigned
+                printer_id=item_printer_id,
+                target_model=item_target_model,
+                required_filament_types=required_filament_types,
                 library_file_id=file_id,
                 project_id=lib_file.project_id
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
@@ -2914,6 +3022,20 @@ async def add_files_to_queue(
             logger.exception("Error adding file %s to queue", file_id)
             errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))
 
+    # Nothing queued and something to say about why. Returning 200 here is what
+    # made this look like a working call that quietly did nothing: a client that
+    # checks the status code sees success, and the reasons sit in a body it had
+    # no cause to read (#3112). Partial success stays 200 -- items really were
+    # created, and the per-file errors belong with them.
+    if not added and errors:
+        raise HTTPException(
+            400,
+            detail={
+                "message": "No files could be added to the queue.",
+                "errors": [e.model_dump() for e in errors],
+            },
+        )
+
     await db.commit()
 
     return AddToQueueResponse(added=added, errors=errors)

+ 7 - 0
backend/app/schemas/library.py

@@ -325,6 +325,13 @@ class AddToQueueRequest(BaseModel):
     """Schema for adding library files to the print queue."""
 
     file_ids: list[int] = Field(..., min_length=1)
+    # Where the items should go. Mutually exclusive, both optional. With
+    # neither, each file's own declared model is used when a printer of that
+    # model is active: an item carrying no printer and no target model matches
+    # neither branch of the scheduler's dispatch, so it is one nothing can ever
+    # pick up (#3112).
+    printer_id: int | None = None
+    target_model: str | None = None
 
 
 class AddToQueueResult(BaseModel):

+ 204 - 9
backend/tests/integration/test_library_api.py

@@ -7,6 +7,16 @@ from pathlib import Path
 
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.print_queue import PrintQueueItem
+
+
+async def _read_queue_item(db_session, item_id: int) -> PrintQueueItem:
+    """Re-read a queue row the route just committed through its own session."""
+    db_session.expire_all()
+    return (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
 
 
 class TestLibraryFoldersAPI:
@@ -679,19 +689,43 @@ class TestLibraryAddToQueueAPI:
 
         return _create_library_file
 
+    @pytest.fixture
+    async def on_disk_file_factory(self, library_file_factory):
+        """A library file whose bytes exist, so the route gets past its disk check."""
+        written: list[Path] = []
+
+        async def _create(**kwargs):
+            counter = len(written) + 1
+            rel_path = kwargs.pop("file_path", f"archive/library/files/queue_probe_{counter}.gcode.3mf")
+            abs_path = Path(app_settings.base_dir) / rel_path
+            abs_path.parent.mkdir(parents=True, exist_ok=True)
+            abs_path.write_bytes(b"probe")
+            written.append(abs_path)
+            kwargs.setdefault("filename", f"queue_probe_{counter}.gcode.3mf")
+            return await library_file_factory(file_path=rel_path, **kwargs)
+
+        yield _create
+
+        for path in written:
+            path.unlink(missing_ok=True)
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
-        """Verify error for non-existent file."""
+        """Nothing queued is not a success (#3112).
+
+        This used to assert 200: the caller got an OK for a call that created
+        nothing, with the reason in a body it had no cause to read. The reason
+        is still reported, now where a failed call puts it.
+        """
         await printer_factory()
 
         data = {"file_ids": [9999]}
         response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
-        assert response.status_code == 200
-        result = response.json()
-        assert len(result["added"]) == 0
-        assert len(result["errors"]) == 1
-        assert result["errors"][0]["file_id"] == 9999
+        assert response.status_code == 400
+        errors = response.json()["detail"]["errors"]
+        assert len(errors) == 1
+        assert errors[0]["file_id"] == 9999
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -708,11 +742,172 @@ class TestLibraryAddToQueueAPI:
 
         data = {"file_ids": [lib_file.id]}
         response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
+        assert response.status_code == 400
+        errors = response.json()["detail"]["errors"]
+        assert len(errors) == 1
+        assert "sliced" in errors[0]["error"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_success_still_returns_200(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, on_disk_file_factory, db_session
+    ):
+        """Items really were created, so the call succeeded (#3112).
+
+        The per-file errors ride along with them, which is the whole point of a
+        bulk endpoint. Only a call that produced nothing is a failed call.
+        """
+        await printer_factory()
+        good = await on_disk_file_factory()
+        bad = await library_file_factory(filename="model.stl", file_path="/test/path/model.stl", file_type="stl")
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [good.id, bad.id]})
         assert response.status_code == 200
         result = response.json()
-        assert len(result["added"]) == 0
-        assert len(result["errors"]) == 1
-        assert "sliced" in result["errors"][0]["error"].lower()
+        assert [a["file_id"] for a in result["added"]] == [good.id]
+        assert [e["file_id"] for e in result["errors"]] == [bad.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_model_is_inferred_so_the_item_can_be_dispatched(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """#3112: an item with no printer and no target model is inert.
+
+        The scheduler dispatches on `item.printer_id` or on
+        `item.target_model or item.variants`; a row with neither matches no
+        branch and waits forever. With an active X1C present, a file that says
+        it was sliced for one is aimed at it.
+        """
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
+        assert response.status_code == 200
+        item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
+        assert item.printer_id is None
+        assert item.target_model == "X1C"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_printer_of_that_model_leaves_the_item_unassigned(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """Aiming an item at hardware nobody owns would only look like progress.
+
+        Having no H2D is the user's situation, not their mistake, so the file
+        is still queued -- as the unassigned row it has always been.
+        """
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
+        assert response.status_code == 200
+        item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
+        assert item.printer_id is None
+        assert item.target_model is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_explicit_printer_wins_over_the_files_own_model(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        printer = await printer_factory(model="X1C")
+        # Read before the queue row is re-read: that expires the session, and
+        # a lazy refresh of this row would then happen outside the greenlet.
+        printer_id = printer.id
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "printer_id": printer_id},
+        )
+        assert response.status_code == 200
+        item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
+        assert item.printer_id == printer_id
+        assert item.target_model is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_incompatible_target_model_is_refused_per_file(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """The same cross-model gate POST /queue/ applies (#2578).
+
+        The scheduler hands model-based items to hardware with no human in the
+        loop, so a file sliced for one model must not be aimed at another.
+        """
+        await printer_factory(model="A1")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "target_model": "A1"},
+        )
+        assert response.status_code == 400
+        assert "cannot be dispatched" in response.json()["detail"]["errors"][0]["error"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_model_without_an_active_printer_is_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "target_model": "H2D"},
+        )
+        assert response.status_code == 400
+        assert "No active printers" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_and_target_model_together_are_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        printer = await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory()
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "printer_id": printer.id, "target_model": "X1C"},
+        )
+        assert response.status_code == 400
+        assert "both" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_printer_is_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory()
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [lib_file.id], "printer_id": 999999},
+        )
+        assert response.status_code == 400
+        assert response.json()["detail"] == "Printer not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_filename_the_printer_cannot_store_is_refused(
+        self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
+    ):
+        """The Bambu SD card is FAT32; an illegal character 553s at upload.
+
+        POST /queue/ has rejected these at queue time since #1540. This route
+        did not, so the mistake surfaced as a print that failed later.
+        """
+        await printer_factory(model="X1C")
+        lib_file = await on_disk_file_factory(filename="bad:name?.gcode.3mf")
+
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
+        assert response.status_code == 400
+        assert response.json()["detail"]["errors"][0]["file_id"] == lib_file.id
 
 
 class TestLibraryZipExtractAPI:

+ 95 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -1907,3 +1907,98 @@ class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
         )
         assert resp.status_code == 404
         assert resp.json()["detail"] == "File not found"
+
+
+class TestLibraryAddToQueueOwnership(TestOwnershipPermissionsSetup):
+    """The bulk add-to-queue path must scope reads the way its siblings do.
+
+    ``POST /library/files/add-to-queue`` resolved its files by raw id and gated
+    only on QUEUE_CREATE, so a READ_OWN operator could queue -- and therefore
+    print, and then hold the archive of -- a file a direct GET on the same id
+    answers 404 for. Same shape as the slice path above.
+
+    An invisible row is dropped before the loop, so it reports as the plain
+    "File not found" an unknown id gets: the response must not say which ids
+    exist. With nothing added the route now answers 400, so the assertions read
+    the reasons out of ``detail``.
+    """
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            defaults = {
+                "filename": f"queue_src_{_counter[0]}.gcode.3mf",
+                "file_path": f"library/queue_src_{_counter[0]}.gcode.3mf",
+                "file_type": "3mf",
+                "file_size": 1024,
+            }
+            defaults.update(kwargs)
+            row = LibraryFile(**defaults)
+            db_session.add(row)
+            await db_session.commit()
+            await db_session.refresh(row)
+            return row
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_queue_others_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        assert resp.status_code == 400
+        errors = resp.json()["detail"]["errors"]
+        assert [e["error"] for e in errors] == ["File not found"]
+        # Indistinguishable from an id that was never there.
+        assert errors[0]["filename"] == "(not found)"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_queue_own_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        """Control: the gate lets the owner through to the on-disk check."""
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+        resp = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        assert resp.status_code == 400
+        errors = resp.json()["detail"]["errors"]
+        assert [e["error"] for e in errors] == ["File not found on disk"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ownerless_file_needs_read_all(self, async_client: AsyncClient, auth_setup, library_file_factory):
+        """A row with no owner is not everyone's row -- fail closed.
+
+        Matches _ensure_library_file_visible, which the read routes use.
+        """
+        file = await library_file_factory(created_by_id=None)
+        resp = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        assert resp.status_code == 400
+        assert resp.json()["detail"]["errors"][0]["error"] == "File not found"
+
+        admin = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={"file_ids": [file.id]},
+        )
+        # READ_ALL sees it and reaches the on-disk check.
+        assert admin.json()["detail"]["errors"][0]["error"] == "File not found on disk"