Explorar el Código

Library: a project saved as .gcode.3mf is a project (issue #3435)

- Studio saves an unsliced project under whatever name it is given;
  one called Foo.gcode.3mf was stored as printable, and printing it
  to a cost centre was refused with "Estimated cost is required for
  cost center prints" (no G-code, no estimate), with no hint why.
- classify_file_type: a readable .gcode.3mf without plate G-code is a
  3mf (threemf_tools.is_unsliced_3mf; unreadable files keep going by
  name).
- POST /queue/ (and each variant) refuses a 3MF without G-code before
  the budget check: "... isn't sliced yet. Slice it first, then print
  the sliced file." Library Add to queue judges 3MFs by content.
- One-shot startup pass re-types the rows already stored; the #1600
  name backfill is one-shot now, or it flipped them back each boot.
maziggy hace 11 horas
padre
commit
157d66ce40

+ 1 - 0
CHANGELOG.md

@@ -217,6 +217,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.
 
 ### Fixed
+- **A project saved as .gcode.3mf is treated as a project (#3435, reported by @Mounting9612)** — Bambu Studio saves an unsliced project under whatever name it is given, and one called `Foo.gcode.3mf` went into the File Manager as a printable file. Printing it to a cost centre was refused with "Estimated cost is required for cost center prints", which says nothing about the cause; without billing it would have gone to the printer. A `.gcode.3mf` that holds no G-code is now stored as a project and offered for slicing, files already in the library are re-checked once on the update, and the queue refuses such a file with "… isn't sliced yet. Slice it first, then print the sliced file." The File Manager's Add to Queue judges a file by what it holds too, so a sliced plate exported as a plain `.3mf` can be queued from there.
 - **A spool swapped in mid-print shows on the printer card (#3434, reported by @0xCyberFortress)** — After a runout, a new spool loaded into the same slot and assigned in Bambuddy stayed invisible until the print ended: the printer reports the slot loaded but blank and refuses a filament setting for the slot it is printing from, so the card showed "?" and "No filament assigned". The card now shows the assigned spool on such a slot in its colour, with an amber marker, names it and offers Unassign, saying that the printer has not taken it yet. Bambuddy also stops sending that setting on every AMS change while the slot feeds the print, which the printer refused each time, and sends it once the print is over.
 - **Spoolman: a Bambu spool keeps its filament preset (#3433, reported by @ojimpo)** — In Spoolman mode, a spool the AMS sync created from a Bambu RFID tray was stored without the preset the tray reports, which the built-in inventory keeps. Assigned later to a slot without a reader, such as the external spool, it was written as the generic filament for its material: a Bambu PLA Basic became Generic PLA. The sync now stores the tray's preset on a new spool and fills it in on an existing Bambu spool that has none (a preset set by hand stays). And a Spoolman spool without a preset keeps the slot's current filament when it fits, as in built-in mode, before falling back to the generic one.
 - **A camera opened from a printer card stays on the Printers page (#2737)** — Since cameras could be opened from any page, every camera overlay was shown on every page, also one opened from a printer card or the Cam Wall, which followed you around the app. Only a camera opened with the sidebar's **Cameras** button is shown everywhere now. One opened from a card or the Cam Wall is hidden on the other pages, still open, and back on returning to Printers; opening it from the **Cameras** button too puts it on every page. Each camera remembers which it is across a reload.

+ 39 - 6
backend/app/api/routes/library.py

@@ -24,7 +24,11 @@ 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, printed_plate_of_multi_plate_file
+from backend.app.api.routes.print_queue import (
+    _extract_filament_types_from_3mf,
+    printed_plate_of_multi_plate_file,
+    unsliced_file_message,
+)
 from backend.app.core.auth import (
     ApiKeyActor,
     QueueReviewRequired,
@@ -126,6 +130,7 @@ from backend.app.utils.threemf_tools import (
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
+    is_unsliced_3mf,
     sanitize_project_settings_sentinels,
     select_plate_gcode_name,
     supports_enabled_in_config,
@@ -259,12 +264,17 @@ def classify_file_type(filename: str, file_path: Path | str | None = None) -> st
     rather than on its name (#2993). The name is not evidence: a plate exported
     from Studio or a print dispatched through the cloud reaches the archive as
     ``Foo.3mf``, G-code and all, and downloading that and re-importing it used
-    to land a fully printable file in the library as a source-only project. The
-    file is not opened when the name already settles it, so the common case
-    still costs nothing.
+    to land a fully printable file in the library as a source-only project.
+    A ``.gcode.3mf`` is checked as well, and one holding no G-code is a ``3mf``
+    (#3435). Only the zip's file list is read, so it stays cheap.
     """
     lower = filename.lower()
     if lower.endswith(".gcode.3mf"):
+        # The other way round as well (#3435): Studio saves an unsliced
+        # project under whatever name it is given, and one called
+        # ``Foo.gcode.3mf`` was offered Print, then refused at the queue.
+        if file_path is not None and is_unsliced_3mf(file_path):
+            return "3mf"
         return "gcode.3mf"
     ext = os.path.splitext(lower)[1]
     file_type = ext[1:] if ext else "unknown"
@@ -3325,8 +3335,10 @@ async def add_files_to_queue(
             errors.append(AddToQueueError(file_id=file_id, filename="(not found)", error="File not found"))
             continue
 
-        # Validate file is sliced
-        if not is_sliced_file(lib_file.filename):
+        # A 3MF is judged by its content below, once it is known to be there;
+        # anything else by its name, as before.
+        lower_name = lib_file.filename.lower()
+        if not lower_name.endswith(".3mf") and not is_sliced_file(lib_file.filename):
             errors.append(
                 AddToQueueError(
                     file_id=file_id,
@@ -3346,6 +3358,27 @@ async def add_files_to_queue(
                 )
                 continue
 
+            # Sliced is what the file holds, not what it is called (#3435): a
+            # project saved as "Foo.gcode.3mf" holds no G-code, and a sliced
+            # plate exported as "Foo.3mf" does (#2993).
+            if lower_name.endswith(".3mf") and is_unsliced_3mf(file_path):
+                errors.append(
+                    AddToQueueError(
+                        file_id=file_id, filename=lib_file.filename, error=unsliced_file_message(lib_file.filename)
+                    )
+                )
+                continue
+            # A zip that cannot be read has said nothing; its name decides, as before.
+            if lower_name.endswith(".3mf") and not carries_gcode(file_path) and not is_sliced_file(lib_file.filename):
+                errors.append(
+                    AddToQueueError(
+                        file_id=file_id,
+                        filename=lib_file.filename,
+                        error="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
+                    )
+                )
+                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

+ 31 - 2
backend/app/api/routes/print_queue.py

@@ -80,6 +80,7 @@ from backend.app.utils.printer_models import (
 from backend.app.utils.threemf_tools import (
     extract_plate_metadata_from_3mf,
     extract_print_time_from_3mf,
+    is_unsliced_3mf,
 )
 
 logger = logging.getLogger(__name__)
@@ -662,6 +663,29 @@ def saved_slicer_ams_mapping(archive: PrintArchive | None, printer_id: int | Non
     return None
 
 
+def unsliced_file_message(filename: str) -> str:
+    return f"{filename} isn't sliced yet. Slice it first, then print the sliced file."
+
+
+def ensure_sliced(file_path: Path | None, filename: str) -> None:
+    """Refuse a 3MF that holds no G-code, whatever its name says (#3435).
+
+    A project saved from Studio as ``Foo.gcode.3mf`` without slicing was taken
+    for a printable file. With a cost centre it was refused for the missing
+    estimate -- "Estimated cost is required for cost center prints", which
+    says nothing about the cause -- and without one it went to the printer.
+    """
+    if file_path is not None and file_path.name.lower().endswith(".3mf") and is_unsliced_3mf(file_path):
+        raise HTTPException(400, unsliced_file_message(filename))
+
+
+def library_file_disk_path(library_file: LibraryFile) -> Path:
+    """Where a library file is on disk: rows store it relative to base_dir,
+    older ones absolute."""
+    lib_path = Path(library_file.file_path)
+    return lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+
+
 def printed_plate_of_multi_plate_file(file_path: Path | None) -> int | None:
     """The plate a dispatch of *file_path* prints when no plate is named, for
     a file with more than one sliced plate; None otherwise.
@@ -1070,6 +1094,8 @@ async def _resolve_queue_variants(
         except InvalidFilenameError as e:
             raise HTTPException(400, str(e)) from e
 
+        ensure_sliced(library_file_disk_path(library_file), library_file.filename)
+
         model = resolve_variant_model(library_file, spec.target_model)
         if not model:
             raise HTTPException(
@@ -1284,8 +1310,11 @@ async def add_to_queue(
     if archive:
         file_path = settings.base_dir / archive.file_path
     elif library_file:
-        lib_path = Path(library_file.file_path)
-        file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+        file_path = library_file_disk_path(library_file)
+    if library_file is not None:
+        ensure_sliced(file_path, library_file.filename)
+    elif archive is not None:
+        ensure_sliced(file_path, archive.filename)
     plate_id = data.plate_id or printed_plate_of_multi_plate_file(file_path)
     if target_model_norm:
         if file_path and file_path.exists():

+ 82 - 7
backend/app/core/database.py

@@ -1353,6 +1353,72 @@ async def _reclassify_sliced_3mf_library_files(conn) -> None:
         )
 
 
+async def _reclassify_unsliced_gcode_3mf_library_files(conn) -> None:
+    """Re-type library rows named ``.gcode.3mf`` that hold no G-code (#3435).
+
+    The reverse of #2993's pass: ``file_type`` trusted a ``.gcode.3mf`` name,
+    and Studio saves an unsliced project under whatever name it is given. Such
+    a file was offered Print and then refused at the queue, with a cost centre
+    as "Estimated cost is required for cost center prints". The forward fix
+    classifies on content; this pass reaches the rows already stored.
+
+    One-shot and local files only, for the same reasons as #2993's pass. Only
+    a zip that opens and holds no G-code is re-typed: a missing file is left
+    as it is.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import is_unsliced_3mf
+
+    flag = "_backfill_3435_unsliced_gcode_3mf_type_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT id, file_path FROM library_files "
+                    "WHERE file_type = 'gcode.3mf' AND deleted_at IS NULL "
+                    "AND file_path IS NOT NULL AND file_path <> '' "
+                    "AND (is_external IS NULL OR is_external = :false_val)"
+                ),
+                {"false_val": False},
+            )
+        ).fetchall()
+
+        reclassified = 0
+        for row in rows:
+            path = Path(row.file_path)
+            if not path.is_absolute():
+                path = settings.base_dir / row.file_path
+            if not is_unsliced_3mf(path):
+                continue
+            await conn.execute(
+                text("UPDATE library_files SET file_type = '3mf' WHERE id = :id"),
+                {"id": row.id},
+            )
+            reclassified += 1
+
+        if reclassified:
+            logger.info(
+                "[#3435] Re-typed %d library file(s) named .gcode.3mf to source 3MF -- they hold no G-code",
+                reclassified,
+            )
+
+        # Marked done even when nothing matched, so the scan never repeats.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 def _hash_hex_colors(value: str) -> str:
     """``FF8800FF,#00AE42`` -> ``#FF8800,#00AE42``: bare 6- or 8-digit hex
     tokens get the ``#`` and lose the alpha byte; everything else is kept."""
@@ -3997,15 +4063,23 @@ async def _run_migrations(conn):
     # family was split between two values depending on how it was ingested.
     # Going forward `classify_file_type()` is canonical; this backfill flips
     # existing legacy `3mf` rows whose filename ends in `.gcode.3mf` to the
-    # canonical compound name. Idempotent (post-update rows no longer match
-    # `file_type = '3mf'`) and dialect-neutral (`LOWER` + `LIKE` work the
+    # canonical compound name. Dialect-neutral (`LOWER` + `LIKE` work the
     # same under SQLite and Postgres).
-    await conn.execute(
-        text(
-            "UPDATE library_files SET file_type = 'gcode.3mf' "
-            "WHERE file_type = '3mf' AND LOWER(filename) LIKE '%.gcode.3mf'"
+    #
+    # One-shot since #3435: a `.gcode.3mf` holding no G-code is now a `3mf`
+    # on purpose (an unsliced project under that name), and re-running this
+    # on every boot turned those rows back into printable files.
+    _flag_1600 = "_backfill_1600_gcode_3mf_type_done"
+    if not (
+        await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": _flag_1600})
+    ).scalar_one_or_none():
+        await conn.execute(
+            text(
+                "UPDATE library_files SET file_type = 'gcode.3mf' "
+                "WHERE file_type = '3mf' AND LOWER(filename) LIKE '%.gcode.3mf'"
+            )
         )
-    )
+        await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": _flag_1600, "v": "true"})
 
     # Migration: Add per-user Bambu Cloud credential columns
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
@@ -5466,6 +5540,7 @@ async def _run_migrations(conn):
     # named `Foo.3mf` filed as a source-only project (#2993). Re-reads the zip
     # already on disk. One-shot, internal rows only; see the function.
     await _reclassify_sliced_3mf_library_files(conn)
+    await _reclassify_unsliced_gcode_3mf_library_files(conn)
 
     # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
     # whether a plug actually feeds the printer's own power — only then may an

+ 15 - 0
backend/app/utils/threemf_tools.py

@@ -1460,6 +1460,21 @@ def carries_gcode(file_path: Path | str) -> bool:
         return False
 
 
+def is_unsliced_3mf(file_path: Path | str) -> bool:
+    """A readable 3MF that carries no G-code: a project, whatever its name says.
+
+    The strict counterpart of ``carries_gcode``, which reads an unreadable file
+    as "no G-code". Here only a zip that opens and holds no plate G-code counts,
+    so a file that is missing or not a zip is never called unsliced -- the
+    callers re-type rows and refuse jobs on this answer (#3435).
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            return not names_carry_gcode(zf.namelist())
+    except (OSError, zipfile.BadZipFile):
+        return False
+
+
 def default_plate_number(names: list[str]) -> int | None:
     """The plate index to dispatch when the caller didn't ask for one.
 

+ 5 - 0
backend/tests/integration/test_print_queue_api.py

@@ -3818,6 +3818,10 @@ class TestForceColorOverridesAreScopedToThePlate:
         file_path = tmp_path / "three_plates.gcode.3mf"
         with zipfile.ZipFile(file_path, "w") as zf:
             zf.writestr("Metadata/slice_info.config", self.THREE_PLATES)
+            # Sliced, or the queue refuses it (#3435). One plate's G-code only:
+            # with more, a job naming no plate would print the default one
+            # rather than the whole file these tests queue.
+            zf.writestr("Metadata/plate_1.gcode", "; plate 1")
 
         archive = PrintArchive(
             filename="three_plates.gcode.3mf",
@@ -3950,6 +3954,7 @@ class TestForceColorOverridesAreScopedToThePlate:
         file_path = tmp_path / "mixed_types.gcode.3mf"
         with zipfile.ZipFile(file_path, "w") as zf:
             zf.writestr("Metadata/slice_info.config", xml)
+            zf.writestr("Metadata/plate_1.gcode", "; plate 1")  # sliced (#3435)
         archive = PrintArchive(
             filename="mixed_types.gcode.3mf",
             print_name="Mixed",

+ 195 - 0
backend/tests/integration/test_unsliced_named_gcode_3mf_3435.py

@@ -0,0 +1,195 @@
+"""A project saved as ``.gcode.3mf`` is still a project (#3435).
+
+Studio saves an unsliced project under whatever name it is given. One called
+``Foo.gcode.3mf`` landed in the library as a printable file, and printing it to
+a cost centre was refused with "Estimated cost is required for cost center
+prints" -- eleven times, eleven duplicate uploads, nothing saying why. The file
+holds no G-code, so there is nothing to estimate and nothing to print.
+"""
+
+import zipfile
+
+import pytest
+from sqlalchemy import text
+
+from backend.app.api.routes.library import classify_file_type
+from backend.app.models.library import LibraryFile
+from backend.app.utils.threemf_tools import is_unsliced_3mf
+
+SLICED = ["3D/3dmodel.model", "Metadata/plate_1.gcode", "Metadata/slice_info.config"]
+PROJECT = ["3D/3dmodel.model", "Metadata/slice_info.config", "Metadata/project_settings.config"]
+
+
+def _zip(path, names):
+    with zipfile.ZipFile(path, "w") as zf:
+        for name in names:
+            zf.writestr(name, b"x")
+    return path
+
+
+class TestClassification:
+    def test_the_reported_file_is_a_project(self, tmp_path):
+        path = _zip(tmp_path / "ImageToStl.com_Unbenannt.gcode.3mf", PROJECT)
+        assert classify_file_type(path.name, path) == "3mf"
+
+    def test_a_sliced_file_stays_sliced(self, tmp_path):
+        path = _zip(tmp_path / "Benchy.gcode.3mf", SLICED)
+        assert classify_file_type(path.name, path) == "gcode.3mf"
+
+    def test_a_file_that_cannot_be_read_goes_by_its_name(self, tmp_path):
+        broken = tmp_path / "x.gcode.3mf"
+        broken.write_bytes(b"PK\x03\x04truncated")
+        assert is_unsliced_3mf(broken) is False
+        assert is_unsliced_3mf(tmp_path / "absent.gcode.3mf") is False
+        assert classify_file_type("x.gcode.3mf", broken) == "gcode.3mf"
+
+
+async def _library_file(
+    db_session, tmp_path, monkeypatch, *, names, filename="Unbenannt.gcode.3mf", file_type="gcode.3mf"
+):
+    from backend.app.core.config import settings as app_settings
+
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    _zip(tmp_path / filename, names)
+    lib_file = LibraryFile(
+        filename=filename,
+        file_path=filename,
+        file_type=file_type,
+        file_size=1,
+        file_hash=filename,
+        source_type="uploaded",
+        file_metadata={"sliced_for_model": "H2D"},
+    )
+    db_session.add(lib_file)
+    await db_session.commit()
+    return lib_file
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestTheQueueSaysWhy:
+    async def test_post_queue_names_the_cause(self, async_client, db_session, printer_factory, tmp_path, monkeypatch):
+        printer = await printer_factory(model="H2D")
+        lib_file = await _library_file(db_session, tmp_path, monkeypatch, names=PROJECT)
+
+        response = await async_client.post(
+            "/api/v1/queue/", json={"library_file_id": lib_file.id, "printer_id": printer.id}
+        )
+
+        assert response.status_code == 400
+        assert (
+            response.json()["detail"]
+            == "Unbenannt.gcode.3mf isn't sliced yet. Slice it first, then print the sliced file."
+        )
+
+    async def test_ahead_of_the_cost_centre_message(
+        self, async_client, db_session, printer_factory, tmp_path, monkeypatch
+    ):
+        """With billing on the estimate was the first thing missing."""
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="billing_enabled", value="true"))
+        await db_session.commit()
+        printer = await printer_factory(model="H2D")
+        lib_file = await _library_file(db_session, tmp_path, monkeypatch, names=PROJECT)
+
+        response = await async_client.post(
+            "/api/v1/queue/", json={"library_file_id": lib_file.id, "printer_id": printer.id, "cost_center_id": 1}
+        )
+
+        assert response.status_code == 400
+        assert "isn't sliced yet" in response.json()["detail"]
+
+    async def test_a_sliced_file_is_queued(self, async_client, db_session, printer_factory, tmp_path, monkeypatch):
+        printer = await printer_factory(model="H2D")
+        lib_file = await _library_file(db_session, tmp_path, monkeypatch, names=SLICED, filename="Benchy.gcode.3mf")
+
+        response = await async_client.post(
+            "/api/v1/queue/", json={"library_file_id": lib_file.id, "printer_id": printer.id}
+        )
+
+        assert response.status_code == 200, response.text
+
+    async def test_bulk_add_judges_the_content(self, async_client, db_session, printer_factory, tmp_path, monkeypatch):
+        printer = await printer_factory(model="H2D")
+        project = await _library_file(db_session, tmp_path, monkeypatch, names=PROJECT)
+        # A sliced plate exported under a plain .3mf name prints (#2993).
+        exported = await _library_file(
+            db_session, tmp_path, monkeypatch, names=SLICED, filename="Plate.3mf", file_type="gcode.3mf"
+        )
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue", json={"file_ids": [project.id, exported.id], "printer_id": printer.id}
+        )
+
+        assert response.status_code == 200, response.text
+        body = response.json()
+        assert [e["error"] for e in body["errors"]] == [
+            "Unbenannt.gcode.3mf isn't sliced yet. Slice it first, then print the sliced file."
+        ]
+        assert [r["file_id"] for r in body["added"]] == [exported.id]
+
+
+@pytest.mark.asyncio
+class TestRowsAlreadyStored:
+    @pytest.fixture(autouse=True)
+    def _sqlite(self, monkeypatch):
+        from backend.app.core import database as database_module, db_dialect
+
+        monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+        monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+        monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+    @pytest.fixture
+    async def engine(self):
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        from backend.app.core.database import Base
+        from backend.tests.unit.test_library_file_type_backfill_migration import _register_all_models
+
+        _register_all_models()
+        eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with eng.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        yield eng
+        await eng.dispose()
+
+    async def _insert(self, conn, file_id, filename, path, file_type="gcode.3mf", external=0):
+        await conn.execute(
+            text(
+                "INSERT INTO library_files (id, filename, file_path, file_type, file_size, is_external, print_count) "
+                "VALUES (:id, :f, :p, :t, 0, :e, 0)"
+            ),
+            {"id": file_id, "f": filename, "p": str(path), "t": file_type, "e": external},
+        )
+
+    async def _types(self, engine):
+        async with engine.connect() as conn:
+            return dict((await conn.execute(text("SELECT id, file_type FROM library_files ORDER BY id"))).fetchall())
+
+    async def test_only_readable_projects_are_re_typed(self, engine, tmp_path):
+        from backend.app.core.database import run_migrations
+
+        async with engine.begin() as conn:
+            await self._insert(conn, 1, "Unbenannt.gcode.3mf", _zip(tmp_path / "a.gcode.3mf", PROJECT))
+            await self._insert(conn, 2, "Benchy.gcode.3mf", _zip(tmp_path / "b.gcode.3mf", SLICED))
+            await self._insert(conn, 3, "Gone.gcode.3mf", tmp_path / "missing.gcode.3mf")
+            await self._insert(conn, 4, "Ext.gcode.3mf", _zip(tmp_path / "c.gcode.3mf", PROJECT), external=1)
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+        assert await self._types(engine) == {1: "3mf", 2: "gcode.3mf", 3: "gcode.3mf", 4: "gcode.3mf"}
+
+    async def test_a_later_boot_does_not_turn_them_back(self, engine, tmp_path):
+        """The #1600 name backfill used to run on every boot and would flip a
+        re-typed project straight back to printable."""
+        from backend.app.core.database import run_migrations
+
+        async with engine.begin() as conn:
+            await self._insert(conn, 1, "Unbenannt.gcode.3mf", _zip(tmp_path / "a.gcode.3mf", PROJECT))
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+        assert await self._types(engine) == {1: "3mf"}