소스 검색

Merge branch 'dev' into feature/upload-prefer-filename-for-name

MartinNYHC 1 개월 전
부모
커밋
d70552771d
50개의 변경된 파일8385개의 추가작업 그리고 57개의 파일을 삭제
  1. 1 0
      CHANGELOG.md
  2. 27 2
      backend/app/api/routes/library.py
  3. 22 1
      backend/app/api/routes/print_queue.py
  4. 65 0
      backend/app/api/routes/printers.py
  5. 42 0
      backend/app/core/auth.py
  6. 131 0
      backend/app/core/database.py
  7. 54 2
      backend/app/main.py
  8. 10 0
      backend/app/models/print_queue.py
  9. 15 0
      backend/app/schemas/slicer.py
  10. 11 5
      backend/app/services/long_lived_tokens.py
  11. 77 4
      backend/app/services/print_scheduler.py
  12. 97 0
      backend/tests/integration/test_library_slice_api.py
  13. 216 0
      backend/tests/integration/test_overlay_status_api.py
  14. 51 0
      backend/tests/integration/test_print_queue_api.py
  15. 3 2
      backend/tests/unit/services/test_long_lived_tokens.py
  16. 75 1
      backend/tests/unit/test_run_filament_helper.py
  17. 217 0
      backend/tests/unit/test_run_filament_plate_scope_2614.py
  18. 138 0
      backend/tests/unit/test_scheduler_reassign_race_2615.py
  19. 13 0
      frontend/scripts/check-i18n-parity.mjs
  20. 73 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  21. 43 0
      frontend/src/__tests__/components/VirtualKeyboard.test.tsx
  22. 4 3
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx
  23. 24 2
      frontend/src/__tests__/contexts/ToastContext.test.tsx
  24. 33 0
      frontend/src/__tests__/pages/CameraTokensPage.test.tsx
  25. 62 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  26. 46 0
      frontend/src/__tests__/utils/interopDefault.test.ts
  27. 35 1
      frontend/src/api/client.ts
  28. 61 4
      frontend/src/components/SliceModal.tsx
  29. 14 1
      frontend/src/components/VirtualKeyboard.tsx
  30. 25 4
      frontend/src/contexts/ToastContext.tsx
  31. 4 1
      frontend/src/i18n/index.ts
  32. 8 0
      frontend/src/i18n/locales/de.ts
  33. 8 0
      frontend/src/i18n/locales/en.ts
  34. 8 0
      frontend/src/i18n/locales/es.ts
  35. 8 0
      frontend/src/i18n/locales/fr.ts
  36. 8 0
      frontend/src/i18n/locales/it.ts
  37. 8 0
      frontend/src/i18n/locales/ja.ts
  38. 8 0
      frontend/src/i18n/locales/ko.ts
  39. 8 0
      frontend/src/i18n/locales/pt-BR.ts
  40. 6471 0
      frontend/src/i18n/locales/ru.ts
  41. 8 0
      frontend/src/i18n/locales/tr.ts
  42. 8 0
      frontend/src/i18n/locales/zh-CN.ts
  43. 8 0
      frontend/src/i18n/locales/zh-TW.ts
  44. 44 4
      frontend/src/pages/CameraTokensPage.tsx
  45. 51 17
      frontend/src/pages/StreamOverlayPage.tsx
  46. 39 0
      frontend/src/utils/interopDefault.ts
  47. 1 0
      static/assets/index-CKAbipPc.css
  48. 0 1
      static/assets/index-CZwzTgpo.css
  49. 0 0
      static/assets/index-DrPqYCte.js
  50. 2 2
      static/index.html

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
CHANGELOG.md


+ 27 - 2
backend/app/api/routes/library.py

@@ -3528,6 +3528,12 @@ async def _run_slicer_with_fallback(
         presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
         presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
 
 
     used_embedded_settings = False
     used_embedded_settings = False
+    # "Slice as designed" (#2611): honour the file's embedded
+    # project_settings.config instead of the picked profile triplet. Only
+    # meaningful for a 3MF that actually carries embedded settings; the UI
+    # gates the toggle on the picked printer matching the design's target,
+    # so this path never re-targets across printer models.
+    embedded_mode = bool(request.use_embedded_settings and is_3mf)
     service = SlicerApiService(api_url)
     service = SlicerApiService(api_url)
 
 
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
@@ -3606,7 +3612,22 @@ async def _run_slicer_with_fallback(
 
 
     try:
     try:
         try:
         try:
-            if use_cross_class_slice_all:
+            if embedded_mode:
+                # No --load-settings: feed the CLI the file's own
+                # project_settings.config untouched so the designer's tweaks
+                # (walls, infill, etc.) drive the slice. primary_bytes is
+                # already sentinel-sanitised above, the same bytes the
+                # crash-fallback uses. The resolved presets go unused here.
+                result = await service.slice_without_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+                used_embedded_settings = True
+            elif use_cross_class_slice_all:
                 from backend.app.services.slicer_3mf_convert import (
                 from backend.app.services.slicer_3mf_convert import (
                     count_plates_in_3mf,
                     count_plates_in_3mf,
                     merge_plate_3mfs,
                     merge_plate_3mfs,
@@ -3708,7 +3729,11 @@ async def _run_slicer_with_fallback(
                 # (e.g. re-slicing an H2D model for an X1C: the object is off
                 # (e.g. re-slicing an H2D model for an X1C: the object is off
                 # the smaller bed). Surface the slicer's reason instead.
                 # the smaller bed). Surface the slicer's reason instead.
                 raise HTTPException(status_code=400, detail=rejection) from exc
                 raise HTTPException(status_code=400, detail=rejection) from exc
-            if not is_3mf:
+            if not is_3mf or embedded_mode:
+                # embedded_mode already sliced with the file's own settings —
+                # there is nothing to fall back TO, so surface the server
+                # error (the outer handler turns it into a 502) instead of
+                # re-running the same embedded slice.
                 raise
                 raise
             logger.warning(
             logger.warning(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",

+ 22 - 1
backend/app/api/routes/print_queue.py

@@ -774,7 +774,10 @@ async def bulk_update_queue_items(
     skipped_count = 0
     skipped_count = 0
 
 
     for item in items:
     for item in items:
-        if item.status != "pending":
+        # Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
+        # editing a claimed row mid-upload would split it from the in-flight
+        # dispatch, so it's excluded from the bulk change (cancel to move it).
+        if item.status != "pending" or item.dispatching_at is not None:
             skipped_count += 1
             skipped_count += 1
             continue
             continue
 
 
@@ -1082,6 +1085,14 @@ async def update_queue_item(
     if item.status != "pending":
     if item.status != "pending":
         raise HTTPException(400, "Can only update pending items")
         raise HTTPException(400, "Can only update pending items")
 
 
+    # Dispatch claim (#2615): the row is pending but a scheduler worker has
+    # already claimed it and is uploading to its printer. Editing now (e.g.
+    # reassigning printer_id) would split the queue row from the in-flight
+    # archive/expected-print/physical command. Reject until dispatch finishes;
+    # to move it, cancel first (the coordinated escape) and re-queue.
+    if item.dispatching_at is not None:
+        raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
+
     update_data = data.model_dump(exclude_unset=True)
     update_data = data.model_dump(exclude_unset=True)
 
 
     # Normalize target_model if being updated
     # Normalize target_model if being updated
@@ -1153,6 +1164,16 @@ async def update_queue_item(
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
         )
         )
 
 
+    # Re-check the dispatch claim right before mutating (#2615). Several awaited
+    # validations ran since the guard above, and a scheduler worker may have
+    # claimed the row in that gap. A fresh read (item isn't dirty yet, so no
+    # autoflush races the check) narrows the window to effectively nothing.
+    claimed = (
+        await db.execute(select(PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item_id))
+    ).scalar_one_or_none()
+    if claimed is not None:
+        raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
+
     for field, value in update_data.items():
     for field, value in update_data.items():
         setattr(item, field, value)
         setattr(item, field, value)
 
 

+ 65 - 0
backend/app/api/routes/printers.py

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core import database
 from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequireCameraStreamTokenIfAuthEnabled,
+    RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     is_auth_enabled,
     is_auth_enabled,
 )
 )
@@ -821,6 +822,70 @@ async def get_printer_status(
     )
     )
 
 
 
 
+@router.get("/{printer_id}/overlay-status")
+async def get_overlay_status(
+    printer_id: int,
+    _: None = RequireOverlayTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Everything the streaming overlay (#2613) draws for one printer.
+
+    A token-authenticated sibling of ``get_printer_status`` for embeds with no
+    login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
+    Deliberately flat and minimal (name, camera rotation, live print state, and
+    the one setting the overlay reads) rather than the full ``PrinterStatus``:
+    a token holder gets exactly the fields the overlay renders, nothing more.
+
+    Unlike the Cam Wall feed this *includes the print filename* — the overlay
+    names the part on screen — which is why it sits behind its own ``overlay``
+    scope rather than ``camwall``.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    time_format = await get_setting(db, "time_format") or "system"
+    state = printer_manager.get_status(printer_id)
+
+    if not state:
+        # Never connected this run — mirror get_printer_status()'s disconnected
+        # shape so the overlay renders its offline state rather than erroring.
+        return {
+            "id": printer_id,
+            "name": printer.name,
+            "camera_rotation": printer.camera_rotation or 0,
+            "connected": False,
+            "state": None,
+            "current_print": None,
+            "gcode_file": None,
+            "progress": None,
+            "remaining_time": None,
+            "layer_num": None,
+            "total_layers": None,
+            "stg_cur_name": None,
+            "time_format": time_format,
+        }
+
+    return {
+        "id": printer_id,
+        "name": printer.name,
+        "camera_rotation": printer.camera_rotation or 0,
+        "connected": state.connected,
+        "state": state.state,
+        "current_print": state.current_print,
+        "gcode_file": state.gcode_file,
+        "progress": state.progress,
+        "remaining_time": state.remaining_time,
+        "layer_num": state.layer_num,
+        "total_layers": state.total_layers,
+        "stg_cur_name": get_derived_status_name(state, printer.model),
+        "time_format": time_format,
+    }
+
+
 @router.get("/{printer_id}/current-print-user")
 @router.get("/{printer_id}/current-print-user")
 async def get_current_print_user(
 async def get_current_print_user(
     printer_id: int,
     printer_id: int,

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

@@ -725,6 +725,23 @@ async def verify_camwall_token(token: str) -> bool:
         return record is not None
         return record is not None
 
 
 
 
+async def verify_overlay_token(token: str) -> bool:
+    """Verify a streaming-overlay token (#2613). Reusable — does not consume it.
+
+    Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
+    the overlay status feed names the file being printed, so it must not be
+    reachable by a ``camwall`` token (which is trusted to hide the part name) or
+    a bare ``camera_stream`` token (handed out for video alone). The 60-minute
+    ephemeral token belongs to a logged-in browser, which reaches the same data
+    through the ordinary printers API and has no need of this endpoint.
+    """
+    async with async_session() as db:
+        from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope="overlay")
+        return record is not None
+
+
 def verify_password(plain_password: str, hashed_password: str) -> bool:
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.
     """Verify a password against a hash.
 
 
@@ -1774,6 +1791,31 @@ def require_camwall_token_if_auth_enabled():
 RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
 RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
 
 
 
 
+def require_overlay_token_if_auth_enabled():
+    """Dependency that validates a streaming-overlay token query param when auth
+    is enabled.
+
+    Used by the read-only overlay status feed (#2613), which OBS (or any
+    embed with no login session) loads with the token in the URL because it
+    has no JWT to carry.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_overlay_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
+            )
+
+    return checker
+
+
+RequireOverlayTokenIfAuthEnabled = Depends(require_overlay_token_if_auth_enabled())
+
+
 def require_ownership_permission(
 def require_ownership_permission(
     all_permission: str | Permission,
     all_permission: str | Permission,
     own_permission: str | Permission,
     own_permission: str | Permission,

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

@@ -586,6 +586,116 @@ async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
         )
         )
 
 
 
 
+async def _migrate_scope_run_filament_to_plate(conn) -> None:
+    """Repair completed print-log rows that stored a multi-plate 3MF's whole-file
+    filament (and cost) instead of the printed plate's (#2614).
+
+    When the AMS tracker measured nothing for a completed run, the per-run filament
+    fell back to ``PrintArchive.filament_used_grams`` — the sum over EVERY plate of
+    the source 3MF (right for the archive card / project rollup, wrong for one
+    printed plate). So each printed plate of a 22-plate file logged the full ~12 kg,
+    inflating lifetime / user / project / filament stats by the plate count. The
+    forward fix scopes new rows; this repairs the rows already written.
+
+    Only completed rows whose stored grams EXACTLY equal the archive's whole-file
+    value are touched — that is the mis-copy signature. Tracker-measured rows (a
+    rounded spool-delta sum) and partial-progress rows (scaled to progress) never
+    match, so they are never clobbered. Cost is scaled by the plate's share of the
+    whole so it stays consistent with the corrected grams. Runs AFTER the #2603
+    archive plate_id backfill so ``print_archives.plate_id`` is populated.
+
+    Gated to run **exactly once** via a settings flag. This is not merely for
+    idempotency: a genuine single-plate print carries a ``plate_id`` too (the UI
+    always sends one), and for it the plate estimate legitimately equals the
+    whole-file value — so those rows match the signature on every boot. Without
+    the one-shot gate we would re-parse every single-plate 3MF on the print log at
+    each startup, a cost that grows without bound with print history. One pass is
+    enough: the forward fix keeps all new rows correct.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+    flag = "_backfill_2614_plate_filament_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 ple.id AS entry_id, ple.filament_used_grams AS grams, ple.cost AS cost, "
+                    "a.plate_id AS plate_id, a.filament_used_grams AS whole_grams, a.file_path AS file_path "
+                    "FROM print_log_entries ple "
+                    "JOIN print_archives a ON a.id = ple.archive_id "
+                    "WHERE ple.status = 'completed' "
+                    "AND a.plate_id IS NOT NULL "
+                    "AND a.file_path IS NOT NULL "
+                    "AND a.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams = a.filament_used_grams"
+                )
+            )
+        ).fetchall()
+
+        corrected = 0
+        grams_removed = 0.0
+        for row in rows:
+            path = Path(row.file_path)
+            if not path.is_absolute():
+                path = settings.base_dir / row.file_path
+            if not path.exists():
+                continue
+            try:
+                plate_grams = extract_plate_metadata_from_3mf(path, row.plate_id).filament_used_grams
+            except Exception as exc:
+                logger.warning(
+                    "[#2614] could not read plate %s of %s for log entry %s: %s",
+                    row.plate_id,
+                    row.file_path,
+                    row.entry_id,
+                    exc,
+                )
+                continue
+            if not plate_grams or plate_grams <= 0:
+                continue
+            new_grams = round(plate_grams, 2)
+            if abs(new_grams - (row.grams or 0)) < 0.01:
+                continue  # nothing to change (e.g. a genuine single-plate file)
+            new_cost = row.cost
+            whole = row.whole_grams or 0
+            if row.cost and whole > 0:
+                new_cost = round(row.cost * (plate_grams / whole), 2)
+            await conn.execute(
+                text("UPDATE print_log_entries SET filament_used_grams = :g, cost = :c WHERE id = :id"),
+                {"g": new_grams, "c": new_cost, "id": row.entry_id},
+            )
+            corrected += 1
+            grams_removed += (row.grams or 0) - new_grams
+
+        if corrected:
+            logger.info(
+                "[#2614] Re-scoped %d completed print-log row(s) from whole-file to plate filament "
+                "(removed %.0f g of over-counted usage from statistics)",
+                corrected,
+                grams_removed,
+            )
+
+        # Mark done unconditionally (even when nothing matched) so this one-shot
+        # never re-scans the print log on subsequent boots. id/timestamps come
+        # from the table's own defaults; "key" is quoted as it's a keyword.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _migrate_drop_library_print_name(conn) -> None:
 async def _migrate_drop_library_print_name(conn) -> None:
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
 
 
@@ -1411,6 +1521,22 @@ async def run_migrations(conn):
         except (OperationalError, ProgrammingError):
         except (OperationalError, ProgrammingError):
             pass  # Already applied
             pass  # Already applied
 
 
+    # Migration: Add dispatching_at claim column to print_queue (#2615). Nullable
+    # timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
+    # TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
+    # exist" on Postgres. On a fresh DB create_all() already built the column, so
+    # the ALTER is swallowed as "already exists".
+    #
+    # Placed AFTER the print_queue_new2 table-recreate above: that recreate
+    # (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
+    # rebuilds print_queue from an explicit column list that doesn't carry this
+    # column, so adding it earlier would let the recreate silently drop it. Adding
+    # it here means it survives that path.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at TIMESTAMP")
+
     # Migration: Add HA energy sensor entity columns to smart_plugs
     # Migration: Add HA energy sensor entity columns to smart_plugs
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
@@ -3566,6 +3692,11 @@ async def run_migrations(conn):
                 )
                 )
             )
             )
 
 
+    # Migration: repair completed print-log rows that stored a multi-plate 3MF's
+    # whole-file filament instead of the printed plate's (#2614). Runs AFTER the
+    # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
+    await _migrate_scope_run_filament_to_plate(conn)
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)

+ 54 - 2
backend/app/main.py

@@ -772,6 +772,41 @@ def _compute_run_filament_grams(
     return None
     return None
 
 
 
 
+def _plate_scoped_run_estimate(archive, full_path) -> tuple[float | None, float | None]:
+    """Per-run (grams, cost) scoped to the plate this run actually printed (#2614).
+
+    ``PrintArchive.filament_used_grams`` / ``.cost`` are the sum over EVERY plate of
+    the source 3MF — correct for the archive card and project rollup, but wrong for a
+    single plate dispatched from a multi-plate file: without scoping, each printed
+    plate of a 22-plate file logs the whole ~12 kg and inflates every statistic. When
+    the archive carries a ``plate_id`` and its 3MF is on disk, return that plate's
+    slicer estimate instead; cost is scaled by the plate's share of the whole so it
+    stays consistent with the scoped grams without re-doing the filament price lookup.
+    Falls back to the archive's whole-file values when there's no plate to scope to.
+    """
+    whole_grams = archive.filament_used_grams
+    if archive.plate_id is None or full_path is None or not full_path.exists():
+        return whole_grams, archive.cost
+    try:
+        from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+        plate_grams = extract_plate_metadata_from_3mf(full_path, archive.plate_id).filament_used_grams
+    except Exception as exc:
+        logging.getLogger(__name__).debug(
+            "[#2614] plate-scoped estimate failed for archive %s (plate %s): %s",
+            archive.id,
+            archive.plate_id,
+            exc,
+        )
+        return whole_grams, archive.cost
+    if not plate_grams or plate_grams <= 0:
+        return whole_grams, archive.cost
+    plate_cost = archive.cost
+    if archive.cost and whole_grams and whole_grams > 0:
+        plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
+    return round(plate_grams, 2), plate_cost
+
+
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     stored_ams_mapping = data.get("ams_mapping")
     stored_ams_mapping = data.get("ams_mapping")
@@ -4791,9 +4826,19 @@ async def on_print_complete(printer_id: int, data: dict):
                 # math (failed / cancelled / stopped get scaled to progress
                 # math (failed / cancelled / stopped get scaled to progress
                 # or to tracked spool deltas).
                 # or to tracked spool deltas).
                 _run_status = data.get("status", "completed")
                 _run_status = data.get("status", "completed")
+                # #2614: scope the per-run estimate to the printed plate. For a
+                # multi-plate 3MF dispatched one plate at a time, the archive's
+                # filament/cost are the whole-file totals; the PrintLogEntry must
+                # reflect only this plate. No effect on single-plate archives (the
+                # plate estimate equals the whole-file value) or on the tracker
+                # path (measured spool deltas win in _compute_run_filament_grams).
+                _est_full_path = (
+                    app_settings.base_dir / archive.file_path if archive.file_path else None
+                )  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
+                _est_grams, _est_cost = _plate_scoped_run_estimate(archive, _est_full_path)
                 _run_grams = _compute_run_filament_grams(
                 _run_grams = _compute_run_filament_grams(
                     _run_status,
                     _run_status,
-                    archive.filament_used_grams,
+                    _est_grams,
                     data.get("progress"),
                     data.get("progress"),
                     usage_results,
                     usage_results,
                 )
                 )
@@ -4806,7 +4851,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 if usage_results:
                 if usage_results:
                     _run_cost = sum(r.get("cost") or 0 for r in usage_results) or None
                     _run_cost = sum(r.get("cost") or 0 for r in usage_results) or None
                 if _run_cost is None and _run_status == "completed":
                 if _run_cost is None and _run_status == "completed":
-                    _run_cost = archive.cost
+                    _run_cost = _est_cost
 
 
                 await write_log_entry(
                 await write_log_entry(
                     db,
                     db,
@@ -6577,6 +6622,13 @@ PUBLIC_API_PATTERNS = [
     # Camera (streams loaded via <img> tag)
     # Camera (streams loaded via <img> tag)
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
+    # Streaming-overlay status feed (#2613): OBS loads /overlay/{id} with no login
+    # and this backs it, authenticated by an ``overlay``-scoped token in the query
+    # string (same reasoning as the camera streams above — no header to carry a
+    # JWT). "Public" only means the middleware steps aside; the route still runs
+    # RequireOverlayTokenIfAuthEnabled, which rejects an absent, expired, revoked,
+    # or wrong-scoped token — a camwall or camera_stream token does NOT open it.
+    "/overlay-status",  # /printers/{id}/overlay-status
     # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
     # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # download token in the URL path instead.
     # download token in the URL path instead.

+ 10 - 0
backend/app/models/print_queue.py

@@ -111,6 +111,16 @@ class PrintQueueItem(Base):
     # Status: pending, printing, completed, failed, skipped, cancelled
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
     status: Mapped[str] = mapped_column(String(20), default="pending")
 
 
+    # Dispatch claim (#2615). Set atomically by the scheduler the moment it
+    # begins dispatching this row and cleared when dispatch ends. The row stays
+    # `status='pending'` throughout the (slow) FTP upload, which left a window
+    # where a concurrent PATCH could reassign printer_id mid-upload and split the
+    # queue row from the archive/expected-print/physical command. While this is
+    # set the edit routes reject changes (409) and the scheduler won't re-select
+    # the row. Startup reconciliation clears any left over by a crash mid-dispatch
+    # (no coroutine survives a restart), so a stale claim never wedges an item.
+    dispatching_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Cleared by the per-printer "Resume after failure" action (#1818) so the
     # Cleared by the per-printer "Resume after failure" action (#1818) so the
     # scheduler's `_check_previous_success` lookback skips this row. Without
     # scheduler's `_check_previous_success` lookback skips this row. Without
     # this, a single `failed` or `aborted` print poisoned every later
     # this, a single `failed` or `aborted` print poisoned every later

+ 15 - 0
backend/app/schemas/slicer.py

@@ -82,6 +82,21 @@ class SliceRequest(BaseModel):
         default=False,
         default=False,
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
     )
     )
+    use_embedded_settings: bool = Field(
+        default=False,
+        description=(
+            "3MF only. Slice using the file's embedded "
+            "``Metadata/project_settings.config`` (the designer's own tweaks — wall "
+            "count, infill, etc.) instead of the picked printer/process/filament "
+            "triplet. This is the 'slice as designed' path: no ``--load-settings`` "
+            "override, so a MakerWorld author's settings survive. Ignored for STL / "
+            "plain-model 3MF (no embedded profile to honour). The preset refs are "
+            "still required by the validator but go unused on this path. Only makes "
+            "sense when the picked printer matches the design's target model — the "
+            "UI gates the toggle on that; there is no cross-printer re-targeting here "
+            "(that is exactly what the profile path is for)."
+        ),
+    )
     bed_type: str | None = Field(
     bed_type: str | None = Field(
         default=None,
         default=None,
         max_length=64,
         max_length=64,

+ 11 - 5
backend/app/services/long_lived_tokens.py

@@ -45,11 +45,17 @@ MAX_TOKEN_LIFETIME_DAYS = 365
 #                   Cam Wall draws: printer names and print state (#2531).
 #                   Cam Wall draws: printer names and print state (#2531).
 #                   Strictly wider than camera_stream, so it gets its own scope
 #                   Strictly wider than camera_stream, so it gets its own scope
 #                   rather than quietly extending tokens already handed out.
 #                   rather than quietly extending tokens already handed out.
-ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall"})
-
-# Scopes the camera stream / snapshot endpoints honour. A Cam Wall token has to
-# be able to pull the video its own tiles are showing.
-STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall")
+#   overlay       — the streaming overlay (#2613): the camera stream plus the
+#                   single-printer status the /overlay page draws, which unlike
+#                   the Cam Wall *includes the print filename*. A distinct grant
+#                   precisely because it reveals the part name a camwall token
+#                   is trusted never to expose, so folding it into camwall would
+#                   silently widen every wall token already handed out.
+ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall", "overlay"})
+
+# Scopes the camera stream / snapshot endpoints honour. A Cam Wall or overlay
+# token has to be able to pull the video its own view is showing.
+STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
 
 
 # Don't write to last_used_at more than once per minute per token. MJPEG
 # Don't write to last_used_at more than once per minute per token. MJPEG
 # streams call verify() at most once per fetch (the browser holds the
 # streams call verify() at most once per fetch (the browser holds the

+ 77 - 4
backend/app/services/print_scheduler.py

@@ -286,6 +286,8 @@ class PrintScheduler:
         self._running = True
         self._running = True
         logger.info("Print scheduler started")
         logger.info("Print scheduler started")
 
 
+        await self._clear_stale_dispatch_claims()
+
         while self._running:
         while self._running:
             dispatched = False
             dispatched = False
             try:
             try:
@@ -297,6 +299,25 @@ class PrintScheduler:
             # not stall behind the idle interval; otherwise sleep normally (#2555).
             # not stall behind the idle interval; otherwise sleep normally (#2555).
             await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
             await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
 
 
+    async def _clear_stale_dispatch_claims(self) -> None:
+        """Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
+
+        A claim is only ever held by a live dispatch coroutine, and no coroutine
+        survives a process restart — so every ``dispatching_at`` present at startup
+        is stale. Clearing them lets those still-pending rows be re-selected for a
+        fresh, consistent dispatch instead of being wedged out of the selection
+        query forever. Called once at the top of ``run()``."""
+        try:
+            async with async_session() as db:
+                res = await db.execute(
+                    update(PrintQueueItem).where(PrintQueueItem.dispatching_at.is_not(None)).values(dispatching_at=None)
+                )
+                await db.commit()
+                if res.rowcount:
+                    logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
+        except Exception as exc:
+            logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
+
     def stop(self):
     def stop(self):
         """Stop the scheduler."""
         """Stop the scheduler."""
         self._running = False
         self._running = False
@@ -320,6 +341,11 @@ class PrintScheduler:
                 result = await db.execute(
                 result = await db.execute(
                     select(PrintQueueItem)
                     select(PrintQueueItem)
                     .where(PrintQueueItem.status == "pending")
                     .where(PrintQueueItem.status == "pending")
+                    # Never re-select a row a dispatch worker has already claimed
+                    # (#2615) — belt-and-suspenders with the _inflight exclusion
+                    # below, and the guard that lets an orphaned claim be ignored
+                    # until startup reconciliation clears it.
+                    .where(PrintQueueItem.dispatching_at.is_(None))
                     # archive/library_file are read by the cross-model gate
                     # archive/library_file are read by the cross-model gate
                     # (#2578); eager-load once per pass instead of a lazy-load
                     # (#2578); eager-load once per pass instead of a lazy-load
                     # (which would raise in async) per item.
                     # (which would raise in async) per item.
@@ -339,6 +365,8 @@ class PrintScheduler:
                 result = await db.execute(
                 result = await db.execute(
                     select(PrintQueueItem)
                     select(PrintQueueItem)
                     .where(PrintQueueItem.status == "pending")
                     .where(PrintQueueItem.status == "pending")
+                    # Skip rows already claimed by a dispatch worker (#2615).
+                    .where(PrintQueueItem.dispatching_at.is_(None))
                     .options(
                     .options(
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.library_file),
                         selectinload(PrintQueueItem.library_file),
@@ -880,11 +908,56 @@ class PrintScheduler:
         transfer's duration.
         transfer's duration.
         """
         """
         async with async_session() as item_db:
         async with async_session() as item_db:
-            item = await item_db.get(PrintQueueItem, item_id)
-            if not item:
-                logger.info("Queue item %s vanished before dispatch — skipping", item_id)
+            # Claim the row for dispatch BEFORE reading the printer snapshot or
+            # touching any slow I/O (#2615). The claim is an atomic CAS on
+            # (status='pending', dispatching_at IS NULL); while it's held the edit
+            # routes reject reassignment (409), so printer_id can't change out from
+            # under the in-flight upload and split the queue row from the
+            # archive/expected-print/physical command.
+            if not await self._claim_for_dispatch(item_db, item_id):
+                logger.info(
+                    "Queue item %s not claimable for dispatch (cancelled, removed, or already claimed) — skipping",
+                    item_id,
+                )
                 return
                 return
-            await self._start_print(item_db, item)
+            try:
+                item = await item_db.get(PrintQueueItem, item_id)
+                if not item:
+                    logger.info("Queue item %s vanished after claim — skipping", item_id)
+                    return
+                await self._start_print(item_db, item)
+            finally:
+                # Release the claim on every exit. Once dispatch has finished the
+                # row's status carries the lock (printing/failed/cancelled are all
+                # != pending), so the token is only needed for the duration of the
+                # upload. A row left pending (e.g. busy-printer deferral) becomes
+                # dispatchable again on the next tick.
+                await self._clear_dispatch_claim(item_db, item_id)
+
+    async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
+        """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
+
+        Returns True if this call won the claim, False if the row was already
+        claimed, no longer pending (cancelled mid-tick), or removed. The CAS is
+        the load-bearing guard against reassign-during-dispatch (#2615)."""
+        res = await db.execute(
+            update(PrintQueueItem)
+            .where(PrintQueueItem.id == item_id)
+            .where(PrintQueueItem.status == "pending")
+            .where(PrintQueueItem.dispatching_at.is_(None))
+            .values(dispatching_at=datetime.now(timezone.utc))
+        )
+        await db.commit()
+        return res.rowcount > 0
+
+    async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
+        """Clear the dispatch claim (#2615). Best-effort: a failure here must not
+        mask the dispatch outcome, and startup reconciliation clears any leftover."""
+        try:
+            await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
+            await db.commit()
+        except Exception as exc:
+            logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
 
 
     async def _find_idle_printer_for_model(
     async def _find_idle_printer_for_model(
         self,
         self,

+ 97 - 0
backend/tests/integration/test_library_slice_api.py

@@ -534,6 +534,103 @@ class TestSliceLibraryFile:
         assert "Metadata/cut_information.xml" in names
         assert "Metadata/cut_information.xml" in names
         assert "3D/3dmodel.model" in names
         assert "3D/3dmodel.model" in names
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_use_embedded_settings_skips_profile_triplet(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        # "Slice as designed" (#2611): with use_embedded_settings the 3MF is
+        # sliced on its own project_settings.config — no --load-settings — so
+        # the sidecar request carries ONLY the model file, never the
+        # printer/process/filament profile parts. Succeeds on the first call
+        # (no crash-fallback), and the result is flagged used_embedded_settings.
+        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "designed.3mf"
+        src_3mf_path.write_bytes(_make_3mf_with_settings({"wall_loops": "5"}))
+        threemf = LibraryFile(
+            filename="designed.3mf",
+            file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="3mf",
+            file_size=src_3mf_path.stat().st_size,
+        )
+        db_session.add(threemf)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+
+        captured: dict = {}
+        call_count = {"n": 0}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            call_count["n"] += 1
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "use_embedded_settings": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is True
+        assert call_count["n"] == 1  # embedded path taken directly, no fallback retry
+
+        # The multipart body must NOT carry any profile part — that is the
+        # whole point of the mode. Their presence would mean --load-settings
+        # ran and overrode the designer's embedded settings.
+        body = captured["body"]
+        assert b"printerProfile" not in body
+        assert b"presetProfile" not in body
+        assert b"filamentProfile" not in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_use_embedded_settings_ignored_for_stl(self, async_client: AsyncClient, slice_test_setup):
+        # An STL has no embedded project settings to honour, so the flag is a
+        # no-op: the normal profile path runs and the triplet is forwarded.
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "1",
+                    "x-filament-used-g": "0",
+                    "x-filament-used-mm": "0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "use_embedded_settings": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is False
+        assert b"printerProfile" in captured["body"]  # profile path still ran
+
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # GET /slice-jobs/{id}
 # GET /slice-jobs/{id}

+ 216 - 0
backend/tests/integration/test_overlay_status_api.py

@@ -0,0 +1,216 @@
+"""Integration tests for the token-authenticated streaming-overlay feed (#2613).
+
+Like the Cam Wall feed, the overlay endpoint exists as its own scope-gated
+route because a kiosk/OBS URL is not a secret. But it is deliberately *wider*
+than the Cam Wall: it names the file being printed (the overlay draws the part
+on screen). So the tests that matter are the scope boundaries — an overlay
+token must not reach the Cam Wall feed and vice versa, a camwall token must not
+reach the overlay feed (that would leak the filename it is trusted to hide) —
+plus the positive path and the disconnected-printer shape.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": f"overlayadmin{suffix}",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
+    response = await async_client.post(
+        "/api/v1/auth/tokens",
+        headers={"Authorization": f"Bearer {jwt}"},
+        json={"name": name, "expires_in_days": 30, "scope": scope},
+    )
+    assert response.status_code == 201, response.text
+    assert response.json()["scope"] == scope
+    return response.json()["token"]
+
+
+@pytest.fixture
+async def printer_row(db_session):
+    """Insert the printer straight into the DB.
+
+    POST /printers probes the real device before it will store a row, and there
+    is no printer on the other end of a test run.
+    """
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="Stream P1S",
+        ip_address="192.168.1.88",
+        access_code="12345678",
+        serial_number="01P00A000000002",
+        model="P1S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    return printer
+
+
+class TestOverlayFeedAuth:
+    async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        await _setup_admin(async_client, suffix="_notoken")
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
+        assert response.status_code == 401
+
+    async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        await _setup_admin(async_client, suffix="_garbage")
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
+        assert response.status_code == 401
+
+    async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
+        """A ``camera_stream`` token was handed out for video alone — it must not
+        acquire the live print status (and filename) just because a new feature
+        shipped.
+        """
+        jwt = await _setup_admin(async_client, suffix="_streamscope")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
+        assert response.status_code == 401
+
+    async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
+        """The crux of a *separate* scope from camwall.
+
+        A Cam Wall token is trusted precisely because it can never name the part
+        being printed. The overlay feed does name it, so a camwall token must be
+        rejected here — otherwise every wall token silently gains filename
+        visibility.
+        """
+        jwt = await _setup_admin(async_client, suffix="_camwallscope")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
+        assert response.status_code == 401
+
+    async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_rightscope")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 200, response.text
+        assert response.json()["name"] == "Stream P1S"
+
+    async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_revoked")
+        created = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers={"Authorization": f"Bearer {jwt}"},
+            json={"name": "obs", "expires_in_days": 30, "scope": "overlay"},
+        )
+        overlay_token = created.json()["token"]
+        await async_client.delete(
+            f"/api/v1/auth/tokens/{created.json()['id']}",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 401
+
+
+class TestOverlayFeedPayload:
+    async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row):
+        """Unlike the Cam Wall, the overlay *does* carry the filename fields —
+        that is what distinguishes the scope. Assert the exact key set so the
+        payload can't silently grow to leak more than the overlay draws.
+        """
+        jwt = await _setup_admin(async_client, suffix="_payload")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 200
+        entry = response.json()
+
+        # Never the secrets — the URL is on a public stream.
+        for leaked in ("serial_number", "ip_address", "access_code"):
+            assert leaked not in entry, f"{leaked} must not be served to an overlay token"
+
+        assert set(entry) == {
+            "id",
+            "name",
+            "camera_rotation",
+            "connected",
+            "state",
+            "current_print",
+            "gcode_file",
+            "progress",
+            "remaining_time",
+            "layer_num",
+            "total_layers",
+            "stg_cur_name",
+            "time_format",
+        }
+
+    async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
+        """No MQTT client runs in tests, so the printer has no state — the
+        overlay must render its offline state rather than erroring.
+        """
+        jwt = await _setup_admin(async_client, suffix="_offline")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        entry = response.json()
+        assert entry["connected"] is False
+        assert entry["state"] is None
+        assert entry["current_print"] is None
+
+    async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
+        """A valid token for a printer id that doesn't exist is a 404 — the token
+        passed the gate, the resource simply isn't there.
+        """
+        jwt = await _setup_admin(async_client, suffix="_404")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
+        assert response.status_code == 404
+
+
+class TestOverlayTokenReachesTheVideo:
+    """The overlay draws the camera feed, so the same token has to satisfy the
+    camera-stream gate.
+    """
+
+    async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_camera_stream_token
+
+        jwt = await _setup_admin(async_client, suffix="_video")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        assert await verify_camera_stream_token(overlay_token) is True
+
+    async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_overlay_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+        camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
+
+        assert await verify_overlay_token(stream_token) is False
+        assert await verify_overlay_token(camwall_token) is False
+
+    async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
+        """Symmetric guard: the new scope must not widen the Cam Wall either."""
+        from backend.app.core.auth import verify_camwall_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate_camwall")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        assert await verify_camwall_token(overlay_token) is False

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

@@ -333,6 +333,57 @@ class TestPrintQueueAPI:
         assert result["bed_levelling"] is False
         assert result["bed_levelling"] is False
         assert result["timelapse"] is True
         assert result["timelapse"] is True
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reassign_rejected_while_dispatching(
+        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
+    ):
+        """#2615: a claimed (in-flight) row rejects edits with 409, so its printer
+        can't be reassigned out from under the running FTP upload."""
+        from datetime import datetime, timezone
+
+        item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
+        other = await printer_factory()
+        original_printer_id = item.printer_id
+
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"printer_id": other.id})
+        assert response.status_code == 409
+
+        await db_session.refresh(item)
+        assert item.printer_id == original_printer_id, "printer_id must not change on a dispatching row"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_skips_dispatching_item(
+        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
+    ):
+        """#2615: bulk edits skip a claimed row rather than splitting it."""
+        from datetime import datetime, timezone
+
+        item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
+        other = await printer_factory()
+        original_printer_id = item.printer_id
+
+        response = await async_client.patch("/api/v1/queue/bulk", json={"item_ids": [item.id], "printer_id": other.id})
+        assert response.status_code == 200
+        body = response.json()
+        assert body["skipped_count"] == 1
+        assert body["updated_count"] == 0
+
+        await db_session.refresh(item)
+        assert item.printer_id == original_printer_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_allowed_on_unclaimed_pending_item(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Regression guard: a normal pending row (no claim) still edits fine."""
+        item = await queue_item_factory()
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 7})
+        assert response.status_code == 200
+        assert response.json()["plate_id"] == 7
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
     async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):

+ 3 - 2
backend/tests/unit/services/test_long_lived_tokens.py

@@ -107,13 +107,14 @@ async def test_create_rejects_expiry_above_policy_cap(db_session, alice: User):
 
 
 
 
 async def test_create_rejects_unsupported_scope(db_session, alice: User):
 async def test_create_rejects_unsupported_scope(db_session, alice: User):
-    """The scope set is closed: ``camera_stream`` (#1108) and ``camwall`` (#2531).
+    """The scope set is closed: ``camera_stream`` (#1108), ``camwall`` (#2531),
+    and ``overlay`` (#2613).
 
 
     Pinned deliberately. Adding a scope should be a decision someone makes on
     Pinned deliberately. Adding a scope should be a decision someone makes on
     purpose — a new value here means a new class of thing a URL-borne token can
     purpose — a new value here means a new class of thing a URL-borne token can
     reach, so it should not be possible to add one without this line failing.
     reach, so it should not be possible to add one without this line failing.
     """
     """
-    assert {"camera_stream", "camwall"} == set(ALLOWED_SCOPES)
+    assert {"camera_stream", "camwall", "overlay"} == set(ALLOWED_SCOPES)
     with pytest.raises(ValueError, match="unsupported scope"):
     with pytest.raises(ValueError, match="unsupported scope"):
         await create_token(
         await create_token(
             db_session,
             db_session,

+ 75 - 1
backend/tests/unit/test_run_filament_helper.py

@@ -6,7 +6,10 @@ don't inflate stats with the full slicer estimate, and tracker-aware so
 completed prints agree with the per-spool counter on the Inventory page.
 completed prints agree with the per-spool counter on the Inventory page.
 """
 """
 
 
-from backend.app.main import _compute_run_filament_grams
+from types import SimpleNamespace
+
+import backend.app.utils.threemf_tools as threemf_tools
+from backend.app.main import _compute_run_filament_grams, _plate_scoped_run_estimate
 
 
 
 
 class TestComputeRunFilamentGrams:
 class TestComputeRunFilamentGrams:
@@ -69,3 +72,74 @@ class TestComputeRunFilamentGrams:
     def test_completed_with_none_estimate_returns_none(self):
     def test_completed_with_none_estimate_returns_none(self):
         # Archive somehow has no estimate (rare; archive_print parsed nothing).
         # Archive somehow has no estimate (rare; archive_print parsed nothing).
         assert _compute_run_filament_grams("completed", None, 100, []) is None
         assert _compute_run_filament_grams("completed", None, 100, []) is None
+
+
+class TestPlateScopedRunEstimate:
+    """#2614: a plate dispatched from a multi-plate 3MF must log only that plate's
+    filament/cost, not the archive's whole-file totals."""
+
+    def _archive(self, **kw):
+        return SimpleNamespace(
+            id=1,
+            plate_id=kw.get("plate_id", 3),
+            filament_used_grams=kw.get("filament_used_grams", 12006.49),
+            cost=kw.get("cost", 240.13),
+            file_path=kw.get("file_path", "archive/1/heart.gcode.3mf"),
+        )
+
+    def _patch_plate_grams(self, monkeypatch, grams):
+        monkeypatch.setattr(
+            threemf_tools,
+            "extract_plate_metadata_from_3mf",
+            lambda path, plate_id: SimpleNamespace(filament_used_grams=grams),
+        )
+
+    def test_scopes_grams_and_scales_cost_to_plate(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), f)
+        assert grams == 350.0
+        # cost scaled by the plate's share of the whole-file grams.
+        assert cost == round(240.13 * (350.0 / 12006.49), 2)
+
+    def test_no_plate_id_returns_whole_file_values(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        # Extractor must not even be consulted.
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(plate_id=None), f)
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_missing_file_returns_whole_file_values(self, monkeypatch):
+        from pathlib import Path
+
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), Path("/nope/gone.3mf"))
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_zero_plate_estimate_falls_back(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        self._patch_plate_grams(monkeypatch, 0.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), f)
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_extractor_error_falls_back(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+
+        def _boom(path, plate_id):
+            raise ValueError("corrupt 3mf")
+
+        monkeypatch.setattr(threemf_tools, "extract_plate_metadata_from_3mf", _boom)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), f)
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_no_archive_cost_keeps_cost_none(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(cost=None), f)
+        assert grams == 350.0
+        assert cost is None

+ 217 - 0
backend/tests/unit/test_run_filament_plate_scope_2614.py

@@ -0,0 +1,217 @@
+"""Backfill for whole-file filament mis-copied onto per-plate print-log rows (#2614).
+
+A plate dispatched from a multi-plate 3MF, when the AMS tracker measured nothing,
+logged the archive's whole-file filament (the sum over every plate) into
+PrintLogEntry.filament_used_grams — inflating stats by the plate count. The
+forward fix scopes new rows; _migrate_scope_run_filament_to_plate repairs the
+rows already written, touching only the exact whole-file mis-copies.
+"""
+
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.utils.threemf_tools as threemf_tools
+from backend.app.core import database as database_module
+from backend.app.core.database import Base, _migrate_scope_run_filament_to_plate
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.printer import Printer
+
+WHOLE = 12006.49  # 22-plate file total
+PLATE = 350.0  # the printed plate's own estimate
+COST = 240.13  # whole-file cost
+
+
+@pytest.fixture
+async def engine(tmp_path):
+    eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    try:
+        yield eng
+    finally:
+        await eng.dispose()
+
+
+@pytest.fixture
+def stub_3mf(tmp_path, monkeypatch):
+    """A stub file on disk + a patched extractor returning the plate estimate."""
+    monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
+    fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
+    fp.parent.mkdir(parents=True)
+    fp.write_bytes(b"stub")
+    monkeypatch.setattr(
+        threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
+    )
+    return "archive/1/heart.gcode.3mf"
+
+
+async def _archive(db, file_path, *, plate_id=3, whole=WHOLE, cost=COST):
+    p = Printer(name="P", serial_number="S", ip_address="1.1.1.1", access_code="c", model="X1C")
+    db.add(p)
+    await db.flush()
+    a = PrintArchive(
+        filename="heart.gcode.3mf",
+        file_path=file_path,
+        file_size=1,
+        status="completed",
+        plate_id=plate_id,
+        filament_used_grams=whole,
+        cost=cost,
+    )
+    db.add(a)
+    await db.flush()
+    return a
+
+
+@pytest.mark.asyncio
+async def test_rescopes_miscopied_row_and_scales_cost(engine, stub_3mf):
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(mis)
+        await db.commit()
+        mis_id = mis.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        fixed = await db.get(PrintLogEntry, mis_id)
+        assert fixed.filament_used_grams == PLATE
+        assert fixed.cost == round(COST * (PLATE / WHOLE), 2)
+
+
+@pytest.mark.asyncio
+async def test_leaves_tracker_measured_and_partial_rows_alone(engine, stub_3mf):
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        # Measured spool delta (rounded), != whole-file → must be untouched.
+        tracked = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=96.5, cost=2.0)
+        # A partial (failed) run scaled to progress, != whole-file → untouched.
+        partial = PrintLogEntry(archive_id=a.id, status="failed", filament_used_grams=1200.6, cost=24.0)
+        db.add_all([tracked, partial])
+        await db.commit()
+        tracked_id, partial_id = tracked.id, partial.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, tracked_id)).filament_used_grams == 96.5
+        assert (await db.get(PrintLogEntry, partial_id)).filament_used_grams == 1200.6
+
+
+@pytest.mark.asyncio
+async def test_idempotent_second_run_is_a_noop(engine, stub_3mf):
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(mis)
+        await db.commit()
+        mis_id = mis.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == PLATE
+
+
+@pytest.mark.asyncio
+async def test_one_shot_gate_prevents_rescan_on_later_boots(engine, stub_3mf):
+    """After the first pass writes its settings flag, a later boot does no work —
+    the migration must never re-scan the print log every startup (single-plate rows
+    legitimately match the whole-file==plate signature forever, so an ungated
+    version would re-parse every single-plate 3MF on each boot)."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        first = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(first)
+        await db.commit()
+        first_id, archive_id = first.id, a.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)  # fixes `first`, writes the flag
+
+    # A fresh mis-copy appears after the one-shot already ran.
+    async with sm() as db:
+        later = PrintLogEntry(archive_id=archive_id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(later)
+        await db.commit()
+        later_id = later.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)  # gate short-circuits; no scan
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, first_id)).filament_used_grams == PLATE
+        # Deliberately untouched: the gate skipped the whole pass. New mis-copies
+        # can't occur anyway — the forward fix scopes every row at write time.
+        assert (await db.get(PrintLogEntry, later_id)).filament_used_grams == WHOLE
+
+
+@pytest.mark.asyncio
+async def test_skips_row_when_3mf_missing(engine, tmp_path, monkeypatch):
+    # base_dir set, but the archive's file was never on disk → row is left alone
+    # (can't compute a plate value; don't guess).
+    monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
+    monkeypatch.setattr(
+        threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
+    )
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, "archive/1/gone.gcode.3mf")
+        mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(mis)
+        await db.commit()
+        mis_id = mis.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == WHOLE
+
+
+@pytest.mark.asyncio
+async def test_single_plate_archive_not_relabelled(engine, tmp_path, monkeypatch):
+    # A genuine single-plate archive whose plate estimate equals the whole-file
+    # value must not be rewritten (no-op guard on unchanged grams).
+    monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
+    fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
+    fp.parent.mkdir(parents=True)
+    fp.write_bytes(b"stub")
+    monkeypatch.setattr(
+        threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda path, plate_id: SimpleNamespace(filament_used_grams=WHOLE),
+    )
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, "archive/1/heart.gcode.3mf", plate_id=1)
+        row = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(row)
+        await db.commit()
+        row_id = row.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        fixed = await db.get(PrintLogEntry, row_id)
+        assert fixed.filament_used_grams == WHOLE
+        assert fixed.cost == COST

+ 138 - 0
backend/tests/unit/test_scheduler_reassign_race_2615.py

@@ -0,0 +1,138 @@
+"""Reassign-during-dispatch race regression (#2615).
+
+A queue row stays ``status='pending'`` for the whole (slow) FTP upload — status
+only flips to ``printing`` at the very end. That left a window where a PATCH
+could reassign ``printer_id`` mid-upload while the in-flight dispatch kept using
+the old printer, splitting the queue row from the archive / expected-print /
+physical command. The fix is a ``dispatching_at`` claim, stamped atomically
+before any slow I/O, that the edit routes reject on and the scheduler won't
+re-select. These tests cover the claim primitives, the guaranteed release, and
+the startup reconciliation that clears a claim orphaned by a crash mid-dispatch.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app.core.database import Base
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+async def ctx():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with sm() as db:
+        printer = Printer(name="P", serial_number="S", ip_address="127.0.0.1", access_code="c", model="X1C")
+        db.add(printer)
+        await db.flush()
+        item = PrintQueueItem(printer_id=printer.id, status="pending")
+        db.add(item)
+        await db.commit()
+        item_id = item.id
+
+    try:
+        yield SimpleNamespace(sm=sm, item_id=item_id, printer_id=printer.id)
+    finally:
+        await engine.dispose()
+
+
+async def _get(ctx, item_id=None):
+    async with ctx.sm() as db:
+        return await db.get(PrintQueueItem, item_id or ctx.item_id)
+
+
+@pytest.mark.asyncio
+async def test_claim_stamps_pending_row_and_is_exclusive(ctx):
+    sched = PrintScheduler()
+    async with ctx.sm() as db:
+        assert await sched._claim_for_dispatch(db, ctx.item_id) is True
+    assert (await _get(ctx)).dispatching_at is not None
+
+    # A second claim on an already-claimed row loses.
+    async with ctx.sm() as db:
+        assert await sched._claim_for_dispatch(db, ctx.item_id) is False
+
+
+@pytest.mark.asyncio
+async def test_claim_fails_on_non_pending_row(ctx):
+    sched = PrintScheduler()
+    async with ctx.sm() as db:
+        item = await db.get(PrintQueueItem, ctx.item_id)
+        item.status = "printing"
+        await db.commit()
+    async with ctx.sm() as db:
+        assert await sched._claim_for_dispatch(db, ctx.item_id) is False
+    assert (await _get(ctx)).dispatching_at is None
+
+
+@pytest.mark.asyncio
+async def test_clear_releases_the_claim(ctx):
+    sched = PrintScheduler()
+    async with ctx.sm() as db:
+        await sched._claim_for_dispatch(db, ctx.item_id)
+    async with ctx.sm() as db:
+        await sched._clear_dispatch_claim(db, ctx.item_id)
+    assert (await _get(ctx)).dispatching_at is None
+
+
+@pytest.mark.asyncio
+async def test_dispatch_one_claims_then_releases_around_start_print(ctx):
+    sched = PrintScheduler()
+    seen = {}
+
+    async def fake_start_print(db, item):
+        # Observe the claim is held while dispatch runs.
+        row = await db.get(PrintQueueItem, item.id)
+        seen["claimed_during"] = row.dispatching_at is not None
+
+    with (
+        patch.object(scheduler_module, "async_session", ctx.sm),
+        patch.object(sched, "_start_print", side_effect=fake_start_print) as sp,
+    ):
+        await sched._dispatch_one(ctx.item_id)
+
+    assert seen["claimed_during"] is True, "claim must be held while dispatch runs"
+    sp.assert_awaited_once()
+    # Released on exit so a deferred (still-pending) row can re-dispatch.
+    assert (await _get(ctx)).dispatching_at is None
+
+
+@pytest.mark.asyncio
+async def test_dispatch_one_skips_an_already_claimed_row(ctx):
+    sched = PrintScheduler()
+    # Pre-claim the row (as if another worker owns it).
+    async with ctx.sm() as db:
+        await sched._claim_for_dispatch(db, ctx.item_id)
+
+    with (
+        patch.object(scheduler_module, "async_session", ctx.sm),
+        patch.object(sched, "_start_print", new=AsyncMock()) as sp,
+    ):
+        await sched._dispatch_one(ctx.item_id)
+
+    sp.assert_not_called()  # claim lost → no dispatch
+    # And it must NOT clear the other worker's claim.
+    assert (await _get(ctx)).dispatching_at is not None
+
+
+@pytest.mark.asyncio
+async def test_startup_reconciliation_clears_stale_claims(ctx):
+    sched = PrintScheduler()
+    async with ctx.sm() as db:
+        await sched._claim_for_dispatch(db, ctx.item_id)
+    assert (await _get(ctx)).dispatching_at is not None
+
+    with patch.object(scheduler_module, "async_session", ctx.sm):
+        await sched._clear_stale_dispatch_claims()
+
+    assert (await _get(ctx)).dispatching_at is None, "a claim orphaned by a restart must be cleared"

+ 13 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -382,6 +382,18 @@ const TR_COGNATES = [
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 ];
 
 
+const RU_COGNATES = [
+  'MakerWorld: {{designer}}',
+  'email',
+  '{{printer}}: {{error}}',
+  'Bambu Cloud',
+  'Orca Cloud',
+  '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+  '({{count}}/8)',
+  '(25%, 50%, 75%)',
+];
+
 const IDENTICAL_TO_EN_ALLOWED = {
 const IDENTICAL_TO_EN_ALLOWED = {
   de: new Set(DE_COGNATES),
   de: new Set(DE_COGNATES),
   fr: new Set(FR_COGNATES),
   fr: new Set(FR_COGNATES),
@@ -393,6 +405,7 @@ const IDENTICAL_TO_EN_ALLOWED = {
   'zh-CN': new Set(ZH_CN_COGNATES),
   'zh-CN': new Set(ZH_CN_COGNATES),
   'zh-TW': new Set(ZH_TW_COGNATES),
   'zh-TW': new Set(ZH_TW_COGNATES),
   tr: new Set(TR_COGNATES),
   tr: new Set(TR_COGNATES),
+  ru: new Set(RU_COGNATES),
 };
 };
 
 
 // Pure comparison logic, exported so tests can verify each failure mode
 // Pure comparison logic, exported so tests can verify each failure mode

+ 73 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -259,6 +259,79 @@ describe('SliceModal', () => {
     await waitFor(() => expect(onClose).toHaveBeenCalled());
     await waitFor(() => expect(onClose).toHaveBeenCalled());
   });
   });
 
 
+  it('offers "use the file\'s built-in settings" when the printer matches the design, and sends the flag (#2611)', async () => {
+    const onClose = vi.fn();
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    // A project 3MF whose embedded printer matches a listed preset — the
+    // printer pre-pick lands on it, so selectedPrinterName === embedded and
+    // the "slice as designed" toggle is offered.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Designed.3mf',
+      plates: [],
+      is_multi_plate: false,
+      embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle',
+      embedded_process: '0.20mm Standard',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose,
+    });
+
+    const user = userEvent.setup();
+    const toggle = (await screen.findByLabelText(
+      /Use the file's built-in settings/,
+    )) as HTMLInputElement;
+    expect(toggle.checked).toBe(false);
+
+    // All preset dropdowns are live until the toggle is on, then bypassed —
+    // the printer included, so changing it can't silently drop the mode.
+    const printerSelect = presetSelects()[0];
+    const processSelect = presetSelects()[1];
+    expect(printerSelect.disabled).toBe(false);
+    expect(processSelect.disabled).toBe(false);
+    await user.click(toggle);
+    expect(printerSelect.disabled).toBe(true);
+    expect(processSelect.disabled).toBe(true);
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+        100,
+        expect.objectContaining({ use_embedded_settings: true }),
+      );
+    });
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('hides the embedded-settings toggle when the picked printer differs from the design (#2611)', async () => {
+    // Embedded target is a model with no matching preset in the listing, so
+    // the printer pre-pick falls back to the local default (Imported X1C),
+    // which does not match — honouring embedded settings would risk the
+    // wrong bed, so the toggle stays hidden.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Designed.3mf',
+      plates: [],
+      is_multi_plate: false,
+      embedded_printer: 'Bambu Lab P1S 0.4 nozzle',
+      embedded_process: '0.20mm Standard',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
+    expect(screen.queryByLabelText(/Use the file's built-in settings/)).toBeNull();
+  });
+
   it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
   it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
     const onClose = vi.fn();
     const onClose = vi.fn();
     mockApi.sliceLibraryFile.mockResolvedValue({
     mockApi.sliceLibraryFile.mockResolvedValue({

+ 43 - 0
frontend/src/__tests__/components/VirtualKeyboard.test.tsx

@@ -0,0 +1,43 @@
+/**
+ * Regression for #2616. react-simple-keyboard ships as CommonJS; under the
+ * bundler's CJS interop the default import can arrive as the module namespace
+ * object rather than the Keyboard component, so rendering <Keyboard> throws
+ * React #130 ("Element type is invalid ... got: object"). The on-screen keyboard
+ * mounts on every SpoolBuddy screen the instant a text input is focused, so the
+ * crash hit inventory search and the write-tag New Spool fields alike.
+ */
+
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { VirtualKeyboard } from '../../components/VirtualKeyboard';
+
+// focusin schedules a 100ms scrollIntoView on the focused input; jsdom doesn't
+// implement it, so stub it or the deferred call throws an unhandled error after
+// the test completes.
+beforeEach(() => {
+  Element.prototype.scrollIntoView = vi.fn();
+});
+
+afterEach(() => {
+  cleanup();
+  vi.restoreAllMocks();
+});
+
+describe('VirtualKeyboard (#2616)', () => {
+  it('renders the keyboard when a text input is focused (no invalid-element-type crash)', () => {
+    render(
+      <div>
+        <input type="text" placeholder="Search spools..." />
+        <VirtualKeyboard />
+      </div>,
+    );
+
+    const input = screen.getByPlaceholderText('Search spools...');
+    // The shell listens on document focusin, so drive a real focus event.
+    fireEvent.focusIn(input);
+
+    // A key from the layout must be on screen — proves <Keyboard> resolved to a
+    // real component instead of throwing on an object element type.
+    expect(screen.getByText('q')).toBeInTheDocument();
+  });
+});

+ 4 - 3
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -104,14 +104,15 @@ describe('SpoolBuddyLayout', () => {
 
 
   it('suppresses the global toast viewport while mounted', () => {
   it('suppresses the global toast viewport while mounted', () => {
     const { unmount } = renderLayout();
     const { unmount } = renderLayout();
-    // Visible viewport gets `hidden` class while the kiosk is up.
-    const viewport = document.querySelector('div.fixed.bottom-4.right-20');
+    // Visible viewport gets `hidden` class while the kiosk is up. Position is
+    // set via safe-area calc() (#2612) so match the stable data-testid.
+    const viewport = document.querySelector('[data-testid="toast-viewport"]');
     expect(viewport?.className).toContain('hidden');
     expect(viewport?.className).toContain('hidden');
 
 
     // Cleanup restores the viewport when the kiosk unmounts (e.g. user
     // Cleanup restores the viewport when the kiosk unmounts (e.g. user
     // navigates back to the main app).
     // navigates back to the main app).
     unmount();
     unmount();
-    const viewportAfter = document.querySelector('div.fixed.bottom-4.right-20');
+    const viewportAfter = document.querySelector('[data-testid="toast-viewport"]');
     // After unmount the toast container is gone with the provider; the
     // After unmount the toast container is gone with the provider; the
     // important guarantee is the suppression flag was untoggled, which the
     // important guarantee is the suppression flag was untoggled, which the
     // ToastContext tests pin directly. Here we only assert no crash on
     // ToastContext tests pin directly. Here we only assert no crash on

+ 24 - 2
frontend/src/__tests__/contexts/ToastContext.test.tsx

@@ -118,8 +118,9 @@ describe('ToastContext viewport suppression', () => {
       </ToastProvider>
       </ToastProvider>
     );
     );
 
 
-    // Toast viewport is the fixed-position container with bottom-4 right-20.
-    const findViewport = () => container.querySelector('div.fixed.bottom-4.right-20');
+    // Toast viewport is the fixed-position container; position is set via
+    // safe-area calc() (#2612) so match the stable data-testid, not classes.
+    const findViewport = () => container.querySelector('[data-testid="toast-viewport"]');
     expect(findViewport()?.className).not.toContain('hidden');
     expect(findViewport()?.className).not.toContain('hidden');
 
 
     act(() => {
     act(() => {
@@ -140,4 +141,25 @@ describe('ToastContext viewport suppression', () => {
     });
     });
     expect(findViewport()?.className).not.toContain('hidden');
     expect(findViewport()?.className).not.toContain('hidden');
   });
   });
+
+  it('caps every toast to the viewport width so it cannot run off-screen (#2612)', () => {
+    const { container, getByTestId } = render(
+      <ToastProvider>
+        <ViewportProbe />
+      </ToastProvider>
+    );
+
+    act(() => {
+      getByTestId('show-toast').click();
+    });
+
+    // The fixed-width dispatch toast (420px) overflowed the left edge of a
+    // phone in an installed PWA. Every toast now carries a viewport-relative
+    // max-width so it stays on-screen; pin it here.
+    const viewport = container.querySelector('[data-testid="toast-viewport"]');
+    const toast = viewport?.querySelector<HTMLElement>('div[style]');
+    expect(toast?.style.maxWidth).toContain('100vw');
+    expect(toast?.style.maxWidth).toContain('safe-area-inset-left');
+    expect(toast?.style.maxWidth).toContain('safe-area-inset-right');
+  });
 });
 });

+ 33 - 0
frontend/src/__tests__/pages/CameraTokensPage.test.tsx

@@ -120,6 +120,39 @@ describe('CameraTokensPage', () => {
     ).not.toBeInTheDocument();
     ).not.toBeInTheDocument();
   });
   });
 
 
+  it('offers the overlay scope and shows a ready-made OBS overlay URL (#2613)', async () => {
+    server.use(
+      http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
+      http.post('*/api/v1/auth/tokens', async ({ request }) => {
+        const body = await request.json();
+        expect(body).toMatchObject({ name: 'OBS', scope: 'overlay' });
+        return HttpResponse.json(
+          token({
+            id: 43,
+            name: 'OBS',
+            scope: 'overlay',
+            token: 'bblt_abcd1234_secretsecretsecretsecretsecret',
+          }),
+          { status: 201 },
+        );
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<CameraTokensPage />);
+
+    await screen.findByText(/no tokens yet/i);
+    await user.type(screen.getByLabelText(/token name/i), 'OBS');
+    await user.selectOptions(screen.getByLabelText(/scope/i), 'overlay');
+    await user.click(screen.getByRole('button', { name: /^create$/i }));
+
+    // The created modal hands over the assembled OBS overlay URL carrying the
+    // token, not just the raw token.
+    expect(await screen.findByText(/overlay url for obs/i)).toBeInTheDocument();
+    const url = screen.getByText(/\/overlay\/1\?token=/);
+    expect(url).toHaveTextContent('token=bblt_abcd1234_secretsecretsecretsecretsecret');
+  });
+
   it('clamps the days input to the 365-day policy cap', async () => {
   it('clamps the days input to the 365-day policy cap', async () => {
     server.use(
     server.use(
       http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
       http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),

+ 62 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -368,4 +368,66 @@ describe('StreamOverlayPage', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('kiosk token mode (#2613)', () => {
+    const mockOverlayPrinting = {
+      id: 1,
+      name: 'X1 Carbon',
+      camera_rotation: 0,
+      connected: true,
+      state: 'RUNNING',
+      current_print: 'KioskBenchy.gcode.3mf',
+      gcode_file: 'plate_1.gcode',
+      progress: 67,
+      remaining_time: 40,
+      layer_num: 10,
+      total_layers: 20,
+      stg_cur_name: null,
+      time_format: 'system',
+    };
+
+    it('reads the token-authed overlay-status feed and carries the token to the camera', async () => {
+      let overlayHit = false;
+      server.use(
+        http.get('/api/v1/printers/:id/overlay-status', () => {
+          overlayHit = true;
+          return HttpResponse.json(mockOverlayPrinting);
+        })
+      );
+
+      renderOverlayPage(1, '?token=obs-tok');
+
+      await waitFor(() => {
+        expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
+      });
+      expect(overlayHit).toBe(true);
+      expect(screen.getByText('67%')).toBeInTheDocument();
+
+      // The camera <img> must carry the same kiosk token — a fresh OBS browser
+      // has no session to mint a camera stream token from.
+      const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+      expect(img.src).toContain('token=obs-tok');
+    });
+
+    it('does not touch the JWT-only status endpoint or a WebSocket in kiosk mode', async () => {
+      let statusHit = false;
+      server.use(
+        http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json(mockOverlayPrinting)),
+        http.get('/api/v1/printers/:id/status', () => {
+          statusHit = true;
+          return HttpResponse.json(mockStatusIdle);
+        })
+      );
+
+      renderOverlayPage(1, '?token=obs-tok');
+
+      await waitFor(() => {
+        expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
+      });
+      // The logged-in status query is disabled when a token is present, so an
+      // unauthenticated OBS browser never fires a doomed 401 (or opens a socket).
+      expect(statusHit).toBe(false);
+      expect(WebSocket).not.toHaveBeenCalled();
+    });
+  });
 });
 });

+ 46 - 0
frontend/src/__tests__/utils/interopDefault.test.ts

@@ -0,0 +1,46 @@
+/**
+ * Unit tests for resolveInteropDefault (#2616).
+ *
+ * The browser build resolved react-simple-keyboard's CommonJS default import to
+ * the module namespace object ({ KeyboardReact, default }) instead of the
+ * component, so <Keyboard> threw React #130 ("got: object"). vitest's own interop
+ * happens to hand back the component, so a render test can't catch the
+ * regression — these assert the resolver directly against both shapes.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { resolveInteropDefault } from '../../utils/interopDefault';
+
+const Comp = function Keyboard() {
+  return null;
+};
+
+describe('resolveInteropDefault', () => {
+  it('returns a bare function component unchanged', () => {
+    expect(resolveInteropDefault(Comp)).toBe(Comp);
+  });
+
+  it('unwraps the CJS interop namespace object via .default (the #2616 shape)', () => {
+    const moduleObject = { default: Comp, KeyboardReact: Comp };
+    expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
+  });
+
+  it('falls back to a named export when there is no .default', () => {
+    const moduleObject = { KeyboardReact: Comp };
+    expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
+  });
+
+  it('leaves a forwardRef/memo object (with $$typeof) untouched', () => {
+    const forwardRefLike = { $$typeof: Symbol.for('react.forward_ref'), render: Comp };
+    expect(resolveInteropDefault(forwardRefLike)).toBe(forwardRefLike);
+  });
+
+  it('returns a string tag unchanged', () => {
+    expect(resolveInteropDefault('div')).toBe('div');
+  });
+
+  it('returns the value unchanged when nothing usable is found', () => {
+    const opaque = { something: 1 };
+    expect(resolveInteropDefault(opaque, ['KeyboardReact'])).toBe(opaque);
+  });
+});

+ 35 - 1
frontend/src/api/client.ts

@@ -294,7 +294,7 @@ export interface SystemHealthResult {
 // 'camera_stream' reaches the video endpoints only. 'camwall' additionally
 // 'camera_stream' reaches the video endpoints only. 'camwall' additionally
 // reaches the read-only Cam Wall feed, which names the printers (#2531), so it
 // reaches the read-only Cam Wall feed, which names the printers (#2531), so it
 // is a separate scope rather than a widening of tokens already in the wild.
 // is a separate scope rather than a widening of tokens already in the wild.
-export type LongLivedTokenScope = 'camera_stream' | 'camwall';
+export type LongLivedTokenScope = 'camera_stream' | 'camwall' | 'overlay';
 
 
 export interface LongLivedCameraToken {
 export interface LongLivedCameraToken {
   id: number;
   id: number;
@@ -324,6 +324,26 @@ export interface CamWallPrinter {
   hms_errors: HMSError[];
   hms_errors: HMSError[];
 }
 }
 
 
+// Streaming-overlay feed (#2613). The subset of print state the /overlay page
+// draws for one printer, served behind an `overlay`-scoped token so OBS embeds
+// with no login session can read it. Unlike CamWallPrinter this names the file
+// being printed (the overlay shows the part on screen).
+export interface OverlayStatus {
+  id: number;
+  name: string;
+  camera_rotation: number;
+  connected: boolean;
+  state: string | null;
+  current_print: string | null;
+  gcode_file: string | null;
+  progress: number | null;
+  remaining_time: number | null;
+  layer_num: number | null;
+  total_layers: number | null;
+  stg_cur_name: string | null;
+  time_format: 'system' | '12h' | '24h';
+}
+
 // Printer types
 // Printer types
 export interface Printer {
 export interface Printer {
   id: number;
   id: number;
@@ -1543,6 +1563,11 @@ export interface SliceRequest {
   // "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
   // "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
   // "Supertack Plate".
   // "Supertack Plate".
   bed_type?: string | null;
   bed_type?: string | null;
+  // "Slice as designed" (#2611). 3MF only: slice using the file's embedded
+  // project_settings.config (the designer's own wall count, infill, etc.)
+  // instead of the picked profile triplet. The preset refs above are still
+  // required by the backend validator but go unused on this path.
+  use_embedded_settings?: boolean;
 }
 }
 
 
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
@@ -5743,6 +5768,15 @@ export const api = {
     request<CamWallPrinter[]>(
     request<CamWallPrinter[]>(
       token ? `/camwall/printers?token=${encodeURIComponent(token)}` : '/camwall/printers',
       token ? `/camwall/printers?token=${encodeURIComponent(token)}` : '/camwall/printers',
     ),
     ),
+  // Token-authenticated streaming-overlay feed (#2613). OBS (or any embed with
+  // no login session) loads /overlay/{id}?token=... and this backs it. `token`
+  // is omitted only when auth is disabled, where the backend gate is a no-op.
+  getOverlayStatus: (printerId: number, token?: string) =>
+    request<OverlayStatus>(
+      token
+        ? `/printers/${printerId}/overlay-status?token=${encodeURIComponent(token)}`
+        : `/printers/${printerId}/overlay-status`,
+    ),
   getCameraStreamUrl: (printerId: number, fps = 10) =>
   getCameraStreamUrl: (printerId: number, fps = 10) =>
     withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
     withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
   getCameraSnapshotUrl: (printerId: number) =>
   getCameraSnapshotUrl: (printerId: number) =>

+ 61 - 4
frontend/src/components/SliceModal.tsx

@@ -220,6 +220,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // user had no way to switch plates without cloning the preset.
   // user had no way to switch plates without cloning the preset.
   const [bedType, setBedType] = useState<string | null>(null);
   const [bedType, setBedType] = useState<string | null>(null);
 
 
+  // "Slice as designed" (#2611). When on, the backend honours the source
+  // 3MF's embedded project_settings.config (the designer's own wall count,
+  // infill, etc.) instead of the picked process/filament profiles. Only
+  // offered when the picked printer matches the design's target model —
+  // see canUseEmbedded below.
+  const [useEmbedded, setUseEmbedded] = useState(false);
+
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // with one pick, or save the current selection as a new pipeline.
   // with one pick, or save the current selection as a new pipeline.
   const pipelinesQuery = useQuery({
   const pipelinesQuery = useQuery({
@@ -380,6 +387,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
   const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
   const embeddedProcess = platesQuery.data?.embedded_process ?? null;
   const embeddedProcess = platesQuery.data?.embedded_process ?? null;
 
 
+  // "Slice as designed" is offered only when the source carries embedded
+  // settings (a real project 3MF, not an STL) AND the picked printer matches
+  // the design's target model. The match gate is load-bearing: honouring
+  // embedded settings for a different model would place the model on the
+  // wrong bed. Names come from the same preset namespace, so a normalised
+  // (strip "# " prefix, case-fold) equality is enough.
+  const canUseEmbedded = useMemo<boolean>(() => {
+    if (!embeddedPrinter || !embeddedProcess || !selectedPrinterName) return false;
+    const norm = (s: string) => s.replace(/^#\s*/, '').trim().toLowerCase();
+    return norm(selectedPrinterName) === norm(embeddedPrinter);
+  }, [embeddedPrinter, embeddedProcess, selectedPrinterName]);
+
+  // Drop back to profile slicing whenever the toggle stops being offered
+  // (e.g. the user switches to a printer that doesn't match the design).
+  useEffect(() => {
+    if (!canUseEmbedded) setUseEmbedded(false);
+  }, [canUseEmbedded]);
+
   // Printer pre-pick: defaults to the printer the 3MF was prepared for when
   // Printer pre-pick: defaults to the printer the 3MF was prepared for when
   // that preset is available, else the first listed printer. Runs once when
   // that preset is available, else the first listed printer. Runs once when
   // presets first arrive; later re-renders preserve any manual choice.
   // presets first arrive; later re-renders preserve any manual choice.
@@ -476,6 +501,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       filament_presets: filamentPresets as PresetRef[],
       filament_presets: filamentPresets as PresetRef[],
       ...(plate != null ? { plate } : {}),
       ...(plate != null ? { plate } : {}),
       ...(bedType != null ? { bed_type: bedType } : {}),
       ...(bedType != null ? { bed_type: bedType } : {}),
+      // The preset refs above are still sent (the backend validator requires
+      // them) but go unused when this flag is set — the slicer falls back on
+      // the file's embedded project_settings.config instead.
+      ...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
     };
     };
   }
   }
 
 
@@ -710,25 +739,53 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 data={presetsQuery.data}
                 data={presetsQuery.data}
                 value={printerPreset}
                 value={printerPreset}
                 onChange={setPrinterPreset}
                 onChange={setPrinterPreset}
-                disabled={isEnqueuing}
+                // Locked in embedded mode too: the picked printer is unused on
+                // the embedded-settings path, and changing it away from the
+                // design's target would drop canUseEmbedded and yank the toggle
+                // out from under the user (#2611).
+                disabled={isEnqueuing || useEmbedded}
               />
               />
+              {/* "Slice as designed" (#2611): honour the file's embedded
+                  settings instead of the picked process/filament. Offered
+                  only when the picked printer matches the design's target. */}
+              {canUseEmbedded && (
+                <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
+                  <input
+                    type="checkbox"
+                    checked={useEmbedded}
+                    onChange={(e) => setUseEmbedded(e.target.checked)}
+                    disabled={isEnqueuing}
+                    className="mt-0.5 cursor-pointer"
+                  />
+                  <span>
+                    {t('slice.useEmbedded')}
+                    <span className="block text-xs text-bambu-gray/70">
+                      {t('slice.useEmbeddedHint')}
+                    </span>
+                  </span>
+                </label>
+              )}
               <PresetDropdown
               <PresetDropdown
                 label={t('slice.process')}
                 label={t('slice.process')}
                 slot="process"
                 slot="process"
                 data={presetsQuery.data}
                 data={presetsQuery.data}
                 value={processPreset}
                 value={processPreset}
                 onChange={setProcessPreset}
                 onChange={setProcessPreset}
-                disabled={isEnqueuing}
+                disabled={isEnqueuing || useEmbedded}
                 selectedPrinterName={selectedPrinterName}
                 selectedPrinterName={selectedPrinterName}
                 compatIndex={compatIndex}
                 compatIndex={compatIndex}
               />
               />
               {/* Bed-type override (#1337). Always visible, always enabled.
               {/* Bed-type override (#1337). Always visible, always enabled.
                   The backend patches curr_bed_type on the resolved process
                   The backend patches curr_bed_type on the resolved process
                   JSON before forwarding to the sidecar. */}
                   JSON before forwarding to the sidecar. */}
+              {/* Bed-type patches curr_bed_type onto the resolved process
+                  JSON, which the embedded-settings path never sends — so it
+                  has no effect there and is disabled to avoid implying it
+                  does. */}
               <BedTypeDropdown
               <BedTypeDropdown
                 value={bedType}
                 value={bedType}
                 onChange={setBedType}
                 onChange={setBedType}
-                disabled={isEnqueuing}
+                disabled={isEnqueuing || useEmbedded}
               />
               />
               {/* Filament reqs may need a server-side preview-slice for
               {/* Filament reqs may need a server-side preview-slice for
                   unsliced project files (single-pass, then cached). Show a
                   unsliced project files (single-pass, then cached). Show a
@@ -775,7 +832,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                           return next;
                           return next;
                         })
                         })
                       }
                       }
-                      disabled={isEnqueuing || !isUsed}
+                      disabled={isEnqueuing || !isUsed || useEmbedded}
                       swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
                       swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
                       selectedPrinterName={selectedPrinterName}
                       selectedPrinterName={selectedPrinterName}
                       compatIndex={compatIndex}
                       compatIndex={compatIndex}

+ 14 - 1
frontend/src/components/VirtualKeyboard.tsx

@@ -1,7 +1,20 @@
 import { useEffect, useRef, useState, useCallback } from 'react';
 import { useEffect, useRef, useState, useCallback } from 'react';
-import Keyboard from 'react-simple-keyboard';
+import KeyboardImport from 'react-simple-keyboard';
 import 'react-simple-keyboard/build/css/index.css';
 import 'react-simple-keyboard/build/css/index.css';
 import './VirtualKeyboard.css';
 import './VirtualKeyboard.css';
+import { resolveInteropDefault } from '../utils/interopDefault';
+
+// react-simple-keyboard is published as CommonJS. Depending on the bundler's
+// CJS->ESM interop, the default import arrives either as the Keyboard component
+// itself or as the module namespace object ({ KeyboardReact, default }). Under
+// the current Vite build (and Node's ESM loader) it's the latter, so rendering
+// <KeyboardImport> puts an object where an element type belongs and React throws
+// "Element type is invalid ... got: object" (#130) — crashing every SpoolBuddy
+// screen the instant a text input is focused and this keyboard mounts (#2616).
+// Resolve the real component defensively so it renders under any interop shape.
+// The TYPE of the default import is already the component (from the .d.ts), so
+// the cast keeps JSX + ref typing intact while fixing only the runtime value.
+const Keyboard = resolveInteropDefault<typeof KeyboardImport>(KeyboardImport, ['KeyboardReact']);
 
 
 const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
 const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
 
 

+ 25 - 4
frontend/src/contexts/ToastContext.tsx

@@ -316,14 +316,32 @@ export function ToastProvider({ children }: { children: ReactNode }) {
 
 
       {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
       {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
           The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
           The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
-          free of main-app notifications. */}
-      <div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
+          free of main-app notifications.
+          Position is set via safe-area-aware calc() rather than bottom-4/right-20 so an
+          installed PWA on a notched phone clears the home indicator / landscape notch
+          (#2612): the 5rem right offset keeps clearance for the bug bubble. */}
+      <div
+        data-testid="toast-viewport"
+        className={`fixed z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}
+        style={{
+          bottom: 'calc(1rem + env(safe-area-inset-bottom))',
+          right: 'calc(5rem + env(safe-area-inset-right))',
+        }}
+      >
         {toasts.map((toast) => (
         {toasts.map((toast) => (
           <div
           <div
             key={toast.id}
             key={toast.id}
             className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
             className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
               toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
               toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
             }`}
             }`}
+            // Cap width to the viewport so the fixed-width dispatch toast (420px)
+            // can't run off the left edge on a phone (#2612). At the cap the toast
+            // sits 1rem + safe-area from the left; on desktop the 420px wins. The
+            // 6rem = the 5rem right offset above + a 1rem left gutter.
+            style={{
+              maxWidth:
+                'calc(100vw - 6rem - env(safe-area-inset-left) - env(safe-area-inset-right))',
+            }}
             data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
             data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
           >
           >
             {toast.dispatchData ? (
             {toast.dispatchData ? (
@@ -389,11 +407,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
                           data-testid={`dispatch-toast-job-${job.jobId}`}
                           data-testid={`dispatch-toast-job-${job.jobId}`}
                         >
                         >
                           <div className="flex items-center justify-between gap-2">
                           <div className="flex items-center justify-between gap-2">
-                            <span className="text-xs text-white truncate" title={job.sourceName}>
+                            {/* min-w-0 + flex-1 lets truncate actually kick in
+                                when the toast is capped to a phone's width
+                                (#2612); the status chip stays put with shrink-0. */}
+                            <span className="text-xs text-white truncate min-w-0 flex-1" title={job.sourceName}>
                               {job.sourceName}
                               {job.sourceName}
                             </span>
                             </span>
                             <span
                             <span
-                              className="text-[11px] uppercase tracking-wide text-bambu-gray"
+                              className="text-[11px] uppercase tracking-wide text-bambu-gray shrink-0"
                               data-testid={`dispatch-toast-status-${job.jobId}`}
                               data-testid={`dispatch-toast-status-${job.jobId}`}
                             >
                             >
                               {t(`dispatchToast.status.${job.status}`)}
                               {t(`dispatchToast.status.${job.status}`)}

+ 4 - 1
frontend/src/i18n/index.ts

@@ -14,6 +14,7 @@ import ptBR from './locales/pt-BR';
 import zhCN from './locales/zh-CN';
 import zhCN from './locales/zh-CN';
 import zhTW from './locales/zh-TW';
 import zhTW from './locales/zh-TW';
 import tr from './locales/tr';
 import tr from './locales/tr';
+import ru from './locales/ru';
 
 
 const resources = {
 const resources = {
   en: { translation: en },
   en: { translation: en },
@@ -27,9 +28,10 @@ const resources = {
   'zh-CN': { translation: zhCN },
   'zh-CN': { translation: zhCN },
   'zh-TW': { translation: zhTW },
   'zh-TW': { translation: zhTW },
   tr: { translation: tr },
   tr: { translation: tr },
+  ru: { translation: ru },
 };
 };
 
 
-const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'tr', 'zh-CN', 'zh-TW'];
+const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'ru', 'tr', 'zh-CN', 'zh-TW'];
 const APPLIANCE_CONSUMED_KEY = 'bambuddy_appliance_locale_consumed';
 const APPLIANCE_CONSUMED_KEY = 'bambuddy_appliance_locale_consumed';
 
 
 i18n
 i18n
@@ -107,4 +109,5 @@ export const availableLanguages = [
   { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
   { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
   { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
   { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
   { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
   { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
+  { code: 'ru', name: 'Russian', nativeName: 'Русский' },
 ];
 ];

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

@@ -4032,6 +4032,8 @@ export default {
     refreshPresets: 'Aktualisieren',
     refreshPresets: 'Aktualisieren',
     refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
     refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
+    useEmbedded: 'Eingebettete Einstellungen der Datei verwenden',
+    useEmbeddedHint: 'So slicen, wie der Ersteller es angelegt hat (Wände, Füllung, Filament), statt mit den obigen Profilen. Verfügbar, weil dein Drucker zur Datei passt.',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     queued: 'In Warteschlange…',
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
@@ -6676,6 +6678,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'Kamera-Stream',
       camera_stream: 'Kamera-Stream',
       camwall: 'Kamera-Wand',
       camwall: 'Kamera-Wand',
+      overlay: 'Streaming-Overlay',
     },
     },
     title: 'Kamera-API-Tokens',
     title: 'Kamera-API-Tokens',
     navTitle: 'Kamera-API-Tokens',
     navTitle: 'Kamera-API-Tokens',
@@ -6694,6 +6697,8 @@ export default {
         'Ein Kamera-Stream-Token kann ausschließlich Kamera-Streams und Schnappschüsse abrufen. Geeignet für Home Assistant, Frigate oder alles, was eine einzelne Kamera einbettet.',
         'Ein Kamera-Stream-Token kann ausschließlich Kamera-Streams und Schnappschüsse abrufen. Geeignet für Home Assistant, Frigate oder alles, was eine einzelne Kamera einbettet.',
       hintCamWall:
       hintCamWall:
         'Ein Kamera-Wand-Token öffnet /camwall auf einem Bildschirm ohne Anmeldung. Es sieht Name und Status jedes Druckers sowie deren Kamera-Streams. Dateinamen, Adressen und Zugangscodes sieht es nicht.',
         'Ein Kamera-Wand-Token öffnet /camwall auf einem Bildschirm ohne Anmeldung. Es sieht Name und Status jedes Druckers sowie deren Kamera-Streams. Dateinamen, Adressen und Zugangscodes sieht es nicht.',
+      hintOverlay:
+        'Ein Streaming-Overlay-Token öffnet /overlay/{printerId} auf einem Bildschirm ohne Anmeldung – für OBS oder jeden Livestream. Es sieht den Kamera-Stream eines Druckers sowie dessen Live-Druckstatus, einschließlich des auf dem Bildschirm angezeigten Dateinamens. Adressen und Zugangscodes sieht es nicht.',
       title: 'Neues Token erstellen',
       title: 'Neues Token erstellen',
       nameLabel: 'Token-Name',
       nameLabel: 'Token-Name',
       namePlaceholder: 'z. B. Home Assistant',
       namePlaceholder: 'z. B. Home Assistant',
@@ -6706,6 +6711,9 @@ export default {
       camWallUrlTitle: 'Kamera-Wand-Adresse für diesen Bildschirm',
       camWallUrlTitle: 'Kamera-Wand-Adresse für diesen Bildschirm',
       camWallUrlHint:
       camWallUrlHint:
         'Diese Adresse auf dem Bildschirm öffnen. Wer die Adresse lesen kann, kann die Kamera-Wand sehen — behandeln Sie sie wie einen Schlüssel. Widerrufen Sie das Token, um den Bildschirm abzuschalten.',
         'Diese Adresse auf dem Bildschirm öffnen. Wer die Adresse lesen kann, kann die Kamera-Wand sehen — behandeln Sie sie wie einen Schlüssel. Widerrufen Sie das Token, um den Bildschirm abzuschalten.',
+      overlayUrlTitle: 'Overlay-Adresse für OBS',
+      overlayUrlHint:
+        'Fügen Sie dies in OBS als Browser-Quelle hinzu. Ändern Sie die Zahl in /overlay/1 auf die Nummer Ihres Druckers (aus dessen Adresse auf der Seite „Drucker“). Wer die Adresse lesen kann, kann den Stream sehen – behandeln Sie sie wie einen Schlüssel und widerrufen Sie das Token, um sie abzuschalten.',
       title: 'Token erstellt – jetzt kopieren',
       title: 'Token erstellt – jetzt kopieren',
       warning:
       warning:
         'Dies ist das einzige Mal, dass dieser Token sichtbar ist. Nach dem Schließen dieses Dialogs können Sie ihn nie wieder anzeigen.',
         'Dies ist das einzige Mal, dass dieser Token sichtbar ist. Nach dem Schließen dieses Dialogs können Sie ihn nie wieder anzeigen.',

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

@@ -4066,6 +4066,8 @@ export default {
     refreshPresets: 'Refresh',
     refreshPresets: 'Refresh',
     refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
     refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
     allPresetsRequired: 'All presets must be selected',
     allPresetsRequired: 'All presets must be selected',
+    useEmbedded: "Use the file's built-in settings",
+    useEmbeddedHint: "Slice it the way the designer set it up (walls, infill, filament) instead of the profiles above. Offered because your printer matches the file's.",
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
@@ -6720,6 +6722,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'Camera stream',
       camera_stream: 'Camera stream',
       camwall: 'Cam Wall',
       camwall: 'Cam Wall',
+      overlay: 'Streaming Overlay',
     },
     },
     title: 'Camera API Tokens',
     title: 'Camera API Tokens',
     navTitle: 'Camera API tokens',
     navTitle: 'Camera API tokens',
@@ -6738,6 +6741,8 @@ export default {
         'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
         'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
       hintCamWall:
       hintCamWall:
         "A Cam Wall token opens /camwall on a screen with no login. It can see every printer's name and state, and their camera streams. It cannot see filenames, addresses or access codes.",
         "A Cam Wall token opens /camwall on a screen with no login. It can see every printer's name and state, and their camera streams. It cannot see filenames, addresses or access codes.",
+      hintOverlay:
+        "A Streaming Overlay token opens /overlay/{printerId} on a screen with no login — for OBS or any live stream. It can see one printer's camera stream plus its live print status, including the filename shown on screen. It cannot see addresses or access codes.",
       title: 'Create new token',
       title: 'Create new token',
       nameLabel: 'Token name',
       nameLabel: 'Token name',
       namePlaceholder: 'e.g. Home Assistant',
       namePlaceholder: 'e.g. Home Assistant',
@@ -6750,6 +6755,9 @@ export default {
       camWallUrlTitle: 'Cam Wall URL for this display',
       camWallUrlTitle: 'Cam Wall URL for this display',
       camWallUrlHint:
       camWallUrlHint:
         'Open this on the screen. Anyone who can read the URL can watch the wall, so treat it like a key — revoke the token to cut the display off.',
         'Open this on the screen. Anyone who can read the URL can watch the wall, so treat it like a key — revoke the token to cut the display off.',
+      overlayUrlTitle: 'Overlay URL for OBS',
+      overlayUrlHint:
+        "Add this as a Browser Source in OBS. Change the /overlay/1 number to your printer's number (from its URL on the Printers page). Anyone who can read the URL can watch the stream, so treat it like a key — revoke the token to cut it off.",
       title: 'Token created — copy it now',
       title: 'Token created — copy it now',
       warning:
       warning:
         'This is the only time this token will be visible. After you close this dialog you can never view it again.',
         'This is the only time this token will be visible. After you close this dialog you can never view it again.',

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

@@ -4035,6 +4035,8 @@ export default {
     refreshPresets: 'Actualizar',
     refreshPresets: 'Actualizar',
     refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
     refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
+    useEmbedded: 'Usar la configuración incorporada del archivo',
+    useEmbeddedHint: 'Laminar tal como lo configuró el diseñador (perímetros, relleno, filamento) en lugar de los perfiles de arriba. Disponible porque tu impresora coincide con la del archivo.',
     enqueuing: 'Enviando el trabajo de laminado…',
     enqueuing: 'Enviando el trabajo de laminado…',
     queued: 'En cola…',
     queued: 'En cola…',
     failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',
     failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',
@@ -6685,6 +6687,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'Transmisión de cámara',
       camera_stream: 'Transmisión de cámara',
       camwall: 'Muro de cámaras',
       camwall: 'Muro de cámaras',
+      overlay: 'Superposición de streaming',
     },
     },
     title: 'Tokens de API de la cámara',
     title: 'Tokens de API de la cámara',
     navTitle: 'Tokens de API de la cámara',
     navTitle: 'Tokens de API de la cámara',
@@ -6703,6 +6706,8 @@ export default {
         'Un token de transmisión de cámara solo puede obtener transmisiones e instantáneas. Úsalo para Home Assistant, Frigate o cualquier cosa que incruste una sola cámara.',
         'Un token de transmisión de cámara solo puede obtener transmisiones e instantáneas. Úsalo para Home Assistant, Frigate o cualquier cosa que incruste una sola cámara.',
       hintCamWall:
       hintCamWall:
         'Un token de muro de cámaras abre /camwall en una pantalla sin iniciar sesión. Puede ver el nombre y el estado de cada impresora, y sus transmisiones de cámara. No puede ver nombres de archivo, direcciones ni códigos de acceso.',
         'Un token de muro de cámaras abre /camwall en una pantalla sin iniciar sesión. Puede ver el nombre y el estado de cada impresora, y sus transmisiones de cámara. No puede ver nombres de archivo, direcciones ni códigos de acceso.',
+      hintOverlay:
+        'Un token de superposición de streaming abre /overlay/{printerId} en una pantalla sin iniciar sesión, para OBS o cualquier transmisión en vivo. Puede ver la transmisión de la cámara de una impresora y su estado de impresión en vivo, incluido el nombre de archivo que aparece en pantalla. No puede ver direcciones ni códigos de acceso.',
       title: 'Crear nuevo token',
       title: 'Crear nuevo token',
       nameLabel: 'Nombre del token',
       nameLabel: 'Nombre del token',
       namePlaceholder: 'p. ej. Home Assistant',
       namePlaceholder: 'p. ej. Home Assistant',
@@ -6715,6 +6720,9 @@ export default {
       camWallUrlTitle: 'Dirección del muro de cámaras para esta pantalla',
       camWallUrlTitle: 'Dirección del muro de cámaras para esta pantalla',
       camWallUrlHint:
       camWallUrlHint:
         'Abre esta dirección en la pantalla. Cualquiera que pueda leerla puede ver el muro, así que trátala como una llave: revoca el token para dejar la pantalla sin acceso.',
         'Abre esta dirección en la pantalla. Cualquiera que pueda leerla puede ver el muro, así que trátala como una llave: revoca el token para dejar la pantalla sin acceso.',
+      overlayUrlTitle: 'Dirección de superposición para OBS',
+      overlayUrlHint:
+        'Agrega esto como Fuente de navegador en OBS. Cambia el número de /overlay/1 por el número de tu impresora (de su dirección en la página Impresoras). Cualquiera que pueda leer la dirección puede ver la transmisión, así que trátala como una llave: revoca el token para cortar el acceso.',
       title: 'Token creado — cópielo ahora',
       title: 'Token creado — cópielo ahora',
       warning:
       warning:
         'Esta es la única vez que este token estará visible. Después de cerrar este diálogo no podrá volver a verlo nunca.',
         'Esta es la única vez que este token estará visible. Después de cerrar este diálogo no podrá volver a verlo nunca.',

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

@@ -4021,6 +4021,8 @@ export default {
     refreshPresets: 'Actualiser',
     refreshPresets: 'Actualiser',
     refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
     refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
+    useEmbedded: 'Utiliser les réglages intégrés du fichier',
+    useEmbeddedHint: "Slicer tel que le concepteur l'a configuré (parois, remplissage, filament) au lieu des profils ci-dessus. Proposé car votre imprimante correspond à celle du fichier.",
     enqueuing: 'Envoi du travail de découpage…',
     enqueuing: 'Envoi du travail de découpage…',
     queued: 'En file d\'attente…',
     queued: 'En file d\'attente…',
     failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',
     failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',
@@ -6664,6 +6666,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'Flux de caméra',
       camera_stream: 'Flux de caméra',
       camwall: 'Mur de caméras',
       camwall: 'Mur de caméras',
+      overlay: 'Incrustation de streaming',
     },
     },
     title: 'Jetons API caméra',
     title: 'Jetons API caméra',
     navTitle: 'Jetons API caméra',
     navTitle: 'Jetons API caméra',
@@ -6682,6 +6685,8 @@ export default {
         'Un jeton de flux de caméra ne peut récupérer que des flux et des instantanés. À utiliser pour Home Assistant, Frigate ou tout ce qui intègre une seule caméra.',
         'Un jeton de flux de caméra ne peut récupérer que des flux et des instantanés. À utiliser pour Home Assistant, Frigate ou tout ce qui intègre une seule caméra.',
       hintCamWall:
       hintCamWall:
         "Un jeton Mur de caméras ouvre /camwall sur un écran sans connexion. Il voit le nom et l'état de chaque imprimante, ainsi que leurs flux de caméra. Il ne voit ni les noms de fichiers, ni les adresses, ni les codes d'accès.",
         "Un jeton Mur de caméras ouvre /camwall sur un écran sans connexion. Il voit le nom et l'état de chaque imprimante, ainsi que leurs flux de caméra. Il ne voit ni les noms de fichiers, ni les adresses, ni les codes d'accès.",
+      hintOverlay:
+        "Un jeton Incrustation de streaming ouvre /overlay/{printerId} sur un écran sans connexion — pour OBS ou tout autre flux en direct. Il voit le flux de caméra d'une imprimante ainsi que son état d'impression en direct, y compris le nom de fichier affiché à l'écran. Il ne voit ni les adresses ni les codes d'accès.",
       title: 'Créer un nouveau jeton',
       title: 'Créer un nouveau jeton',
       nameLabel: 'Nom du jeton',
       nameLabel: 'Nom du jeton',
       namePlaceholder: 'par ex. Home Assistant',
       namePlaceholder: 'par ex. Home Assistant',
@@ -6694,6 +6699,9 @@ export default {
       camWallUrlTitle: 'Adresse du mur de caméras pour cet écran',
       camWallUrlTitle: 'Adresse du mur de caméras pour cet écran',
       camWallUrlHint:
       camWallUrlHint:
         "Ouvrez cette adresse sur l'écran. Quiconque peut lire l'adresse peut regarder le mur : traitez-la comme une clé. Révoquez le jeton pour couper l'écran.",
         "Ouvrez cette adresse sur l'écran. Quiconque peut lire l'adresse peut regarder le mur : traitez-la comme une clé. Révoquez le jeton pour couper l'écran.",
+      overlayUrlTitle: "Adresse d'incrustation pour OBS",
+      overlayUrlHint:
+        "Ajoutez ceci comme Source navigateur dans OBS. Remplacez le numéro dans /overlay/1 par le numéro de votre imprimante (indiqué dans son adresse sur la page Imprimantes). Quiconque peut lire l'adresse peut regarder le flux : traitez-la comme une clé et révoquez le jeton pour couper l'accès.",
       title: 'Jeton créé – copiez-le maintenant',
       title: 'Jeton créé – copiez-le maintenant',
       warning:
       warning:
         'C\'est la seule fois où ce jeton sera visible. Après la fermeture de ce dialogue, vous ne pourrez plus jamais le voir.',
         'C\'est la seule fois où ce jeton sera visible. Après la fermeture de ce dialogue, vous ne pourrez plus jamais le voir.',

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

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: 'Aggiorna',
     refreshPresets: 'Aggiorna',
     refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
     refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
+    useEmbedded: 'Usa le impostazioni integrate del file',
+    useEmbeddedHint: 'Slicia come impostato dal designer (pareti, riempimento, filamento) invece dei profili sopra. Disponibile perché la tua stampante corrisponde a quella del file.',
     enqueuing: 'Invio lavoro di slicing…',
     enqueuing: 'Invio lavoro di slicing…',
     queued: 'In coda…',
     queued: 'In coda…',
     failed: 'Slicing fallito. Controlla i log del sidecar.',
     failed: 'Slicing fallito. Controlla i log del sidecar.',
@@ -6663,6 +6665,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'Flusso della telecamera',
       camera_stream: 'Flusso della telecamera',
       camwall: 'Muro telecamere',
       camwall: 'Muro telecamere',
+      overlay: 'Overlay di streaming',
     },
     },
     title: 'Token API telecamera',
     title: 'Token API telecamera',
     navTitle: 'Token API telecamera',
     navTitle: 'Token API telecamera',
@@ -6681,6 +6684,8 @@ export default {
         'Un token del flusso della telecamera può recuperare soltanto flussi e istantanee. Usalo per Home Assistant, Frigate o qualsiasi cosa incorpori una singola telecamera.',
         'Un token del flusso della telecamera può recuperare soltanto flussi e istantanee. Usalo per Home Assistant, Frigate o qualsiasi cosa incorpori una singola telecamera.',
       hintCamWall:
       hintCamWall:
         'Un token Muro telecamere apre /camwall su uno schermo senza login. Vede nome e stato di ogni stampante e i relativi flussi della telecamera. Non vede nomi di file, indirizzi o codici di accesso.',
         'Un token Muro telecamere apre /camwall su uno schermo senza login. Vede nome e stato di ogni stampante e i relativi flussi della telecamera. Non vede nomi di file, indirizzi o codici di accesso.',
+      hintOverlay:
+        'Un token Overlay di streaming apre /overlay/{printerId} su uno schermo senza login, per OBS o qualsiasi diretta streaming. Vede il flusso della telecamera di una stampante e il suo stato di stampa in tempo reale, incluso il nome del file mostrato sullo schermo. Non vede indirizzi o codici di accesso.',
       title: 'Crea nuovo token',
       title: 'Crea nuovo token',
       nameLabel: 'Nome token',
       nameLabel: 'Nome token',
       namePlaceholder: 'es. Home Assistant',
       namePlaceholder: 'es. Home Assistant',
@@ -6693,6 +6698,9 @@ export default {
       camWallUrlTitle: 'Indirizzo del muro telecamere per questo schermo',
       camWallUrlTitle: 'Indirizzo del muro telecamere per questo schermo',
       camWallUrlHint:
       camWallUrlHint:
         'Apri questo indirizzo sullo schermo. Chiunque possa leggerlo può guardare il muro, quindi trattalo come una chiave: revoca il token per escludere lo schermo.',
         'Apri questo indirizzo sullo schermo. Chiunque possa leggerlo può guardare il muro, quindi trattalo come una chiave: revoca il token per escludere lo schermo.',
+      overlayUrlTitle: 'Indirizzo overlay per OBS',
+      overlayUrlHint:
+        "Aggiungi questo come Sorgente browser in OBS. Cambia il numero in /overlay/1 con il numero della tua stampante (dall'indirizzo nella pagina Stampanti). Chiunque possa leggere l'indirizzo può guardare lo streaming, quindi trattalo come una chiave: revoca il token per interrompere l'accesso.",
       title: 'Token creato – copialo ora',
       title: 'Token creato – copialo ora',
       warning:
       warning:
         'Questa è l\'unica volta in cui questo token sarà visibile. Dopo la chiusura di questa finestra non potrai più visualizzarlo.',
         'Questa è l\'unica volta in cui questo token sarà visibile. Dopo la chiusura di questa finestra non potrai più visualizzarlo.',

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

@@ -4032,6 +4032,8 @@ export default {
     refreshPresets: '再読み込み',
     refreshPresets: '再読み込み',
     refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
     refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
+    useEmbedded: 'ファイルに埋め込まれた設定を使用',
+    useEmbeddedHint: '上のプロファイルではなく、設計者が設定したとおり(ウォール、インフィル、フィラメント)にスライスします。お使いのプリンターがファイルと一致するため利用できます。',
     enqueuing: 'スライスジョブを送信中…',
     enqueuing: 'スライスジョブを送信中…',
     queued: '待機中…',
     queued: '待機中…',
     failed: 'スライスに失敗。サイドカーのログを確認してください。',
     failed: 'スライスに失敗。サイドカーのログを確認してください。',
@@ -6675,6 +6677,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'カメラストリーム',
       camera_stream: 'カメラストリーム',
       camwall: 'カメラウォール',
       camwall: 'カメラウォール',
+      overlay: '配信オーバーレイ',
     },
     },
     title: 'カメラAPIトークン',
     title: 'カメラAPIトークン',
     navTitle: 'カメラAPIトークン',
     navTitle: 'カメラAPIトークン',
@@ -6693,6 +6696,8 @@ export default {
         'カメラストリームトークンで取得できるのは、カメラの映像とスナップショットだけです。Home Assistant や Frigate など、単一のカメラを埋め込む用途に使用してください。',
         'カメラストリームトークンで取得できるのは、カメラの映像とスナップショットだけです。Home Assistant や Frigate など、単一のカメラを埋め込む用途に使用してください。',
       hintCamWall:
       hintCamWall:
         'カメラウォールトークンは、ログインなしの画面で /camwall を開きます。各プリンターの名前と状態、そしてカメラ映像を見ることができます。ファイル名、アドレス、アクセスコードは見えません。',
         'カメラウォールトークンは、ログインなしの画面で /camwall を開きます。各プリンターの名前と状態、そしてカメラ映像を見ることができます。ファイル名、アドレス、アクセスコードは見えません。',
+      hintOverlay:
+        '配信オーバーレイトークンは、ログインなしの画面で /overlay/{printerId} を開きます — OBS やライブ配信向けです。1台のプリンターのカメラ映像に加え、画面に表示されるファイル名を含むライブの印刷状況を見ることができます。アドレスやアクセスコードは見えません。',
       title: '新しいトークンを作成',
       title: '新しいトークンを作成',
       nameLabel: 'トークン名',
       nameLabel: 'トークン名',
       namePlaceholder: '例:Home Assistant',
       namePlaceholder: '例:Home Assistant',
@@ -6705,6 +6710,9 @@ export default {
       camWallUrlTitle: 'この画面用のカメラウォール URL',
       camWallUrlTitle: 'この画面用のカメラウォール URL',
       camWallUrlHint:
       camWallUrlHint:
         'この URL を画面で開いてください。URL を読める人は誰でもウォールを見られるため、鍵と同じように扱ってください。トークンを取り消すと、その画面は遮断されます。',
         'この URL を画面で開いてください。URL を読める人は誰でもウォールを見られるため、鍵と同じように扱ってください。トークンを取り消すと、その画面は遮断されます。',
+      overlayUrlTitle: 'OBS 用のオーバーレイ URL',
+      overlayUrlHint:
+        'これを OBS の「ブラウザ」ソース(Browser Source)として追加してください。/overlay/1 の番号を、お使いのプリンターの番号(プリンターページの URL に表示)に変更します。URL を読める人は誰でも配信を見られるため、鍵と同じように扱い、遮断するにはトークンを取り消してください。',
       title: 'トークンを作成しました – 今すぐコピー',
       title: 'トークンを作成しました – 今すぐコピー',
       warning:
       warning:
         'このトークンが表示されるのは今回限りです。このダイアログを閉じると二度と表示できません。',
         'このトークンが表示されるのは今回限りです。このダイアログを閉じると二度と表示できません。',

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

@@ -3821,6 +3821,8 @@ export default {
     refreshPresets: '새로 고침',
     refreshPresets: '새로 고침',
     refreshPresetsTitle: '프리셋 새로 고침 — 최신 클라우드 및 번들 목록 가져오기 (Bambu Studio 또는 Bambu Handy에서 프리셋 삭제 후 사용)',
     refreshPresetsTitle: '프리셋 새로 고침 — 최신 클라우드 및 번들 목록 가져오기 (Bambu Studio 또는 Bambu Handy에서 프리셋 삭제 후 사용)',
     allPresetsRequired: '모든 프리셋을 선택해야 합니다',
     allPresetsRequired: '모든 프리셋을 선택해야 합니다',
+    useEmbedded: '파일에 포함된 설정 사용',
+    useEmbeddedHint: '위 프로필 대신 디자이너가 설정한 대로(벽, 내부 채움, 필라멘트) 슬라이싱합니다. 프린터가 파일과 일치하여 사용할 수 있습니다.',
     enqueuing: '슬라이싱 작업 제출 중…',
     enqueuing: '슬라이싱 작업 제출 중…',
     queued: '대기 중…',
     queued: '대기 중…',
     failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',
     failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',
@@ -6145,6 +6147,7 @@ export default {
     scope: {
     scope: {
       camera_stream: '카메라 스트림',
       camera_stream: '카메라 스트림',
       camwall: '카메라 월',
       camwall: '카메라 월',
+      overlay: '스트리밍 오버레이',
     },
     },
     title: '카메라 API 토큰',
     title: '카메라 API 토큰',
     navTitle: '카메라 API 토큰',
     navTitle: '카메라 API 토큰',
@@ -6163,6 +6166,8 @@ export default {
         '카메라 스트림 토큰은 카메라 스트림과 스냅숏만 가져올 수 있습니다. Home Assistant, Frigate 등 카메라 하나를 삽입하는 용도로 사용하세요.',
         '카메라 스트림 토큰은 카메라 스트림과 스냅숏만 가져올 수 있습니다. Home Assistant, Frigate 등 카메라 하나를 삽입하는 용도로 사용하세요.',
       hintCamWall:
       hintCamWall:
         '카메라 월 토큰은 로그인 없이 화면에서 /camwall을 엽니다. 모든 프린터의 이름과 상태, 카메라 스트림을 볼 수 있습니다. 파일 이름, 주소, 액세스 코드는 볼 수 없습니다.',
         '카메라 월 토큰은 로그인 없이 화면에서 /camwall을 엽니다. 모든 프린터의 이름과 상태, 카메라 스트림을 볼 수 있습니다. 파일 이름, 주소, 액세스 코드는 볼 수 없습니다.',
+      hintOverlay:
+        '스트리밍 오버레이 토큰은 로그인 없이 화면에서 /overlay/{printerId}을 엽니다 — OBS나 모든 라이브 방송용입니다. 프린터 한 대의 카메라 스트림과 화면에 표시되는 파일 이름을 포함한 실시간 인쇄 상태를 볼 수 있습니다. 주소나 액세스 코드는 볼 수 없습니다.',
       title: '새 토큰 만들기',
       title: '새 토큰 만들기',
       nameLabel: '토큰 이름',
       nameLabel: '토큰 이름',
       namePlaceholder: '예: Home Assistant',
       namePlaceholder: '예: Home Assistant',
@@ -6174,6 +6179,9 @@ export default {
       camWallUrlTitle: '이 화면용 카메라 월 주소',
       camWallUrlTitle: '이 화면용 카메라 월 주소',
       camWallUrlHint:
       camWallUrlHint:
         '이 주소를 화면에서 여세요. 주소를 읽을 수 있는 사람은 누구나 월을 볼 수 있으므로 열쇠처럼 다루세요. 토큰을 취소하면 해당 화면의 접근이 차단됩니다.',
         '이 주소를 화면에서 여세요. 주소를 읽을 수 있는 사람은 누구나 월을 볼 수 있으므로 열쇠처럼 다루세요. 토큰을 취소하면 해당 화면의 접근이 차단됩니다.',
+      overlayUrlTitle: 'OBS용 오버레이 주소',
+      overlayUrlHint:
+        'OBS에서 이것을 브라우저 소스로 추가하세요. /overlay/1의 숫자를 프린터의 번호(프린터 페이지의 주소에 표시됨)로 변경하세요. 주소를 읽을 수 있는 사람은 누구나 스트림을 볼 수 있으므로 열쇠처럼 다루세요 — 접근을 차단하려면 토큰을 취소하세요.',
       title: '토큰 생성됨 — 지금 복사하세요',
       title: '토큰 생성됨 — 지금 복사하세요',
       warning: '이 토큰은 이 번만 볼 수 있습니다. 이 대화상자를 닫으면 다시는 볼 수 없습니다.',
       warning: '이 토큰은 이 번만 볼 수 있습니다. 이 대화상자를 닫으면 다시는 볼 수 없습니다.',
       copy: '복사',
       copy: '복사',

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

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: 'Atualizar',
     refreshPresets: 'Atualizar',
     refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
     refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
+    useEmbedded: 'Usar as configurações incorporadas do arquivo',
+    useEmbeddedHint: 'Fatiar como o designer configurou (paredes, preenchimento, filamento) em vez dos perfis acima. Disponível porque sua impressora corresponde à do arquivo.',
     enqueuing: 'Enviando trabalho de fatiamento…',
     enqueuing: 'Enviando trabalho de fatiamento…',
     queued: 'Na fila…',
     queued: 'Na fila…',
     failed: 'Falha ao fatiar. Verifique os logs do sidecar.',
     failed: 'Falha ao fatiar. Verifique os logs do sidecar.',
@@ -6663,6 +6665,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'Transmissão da câmera',
       camera_stream: 'Transmissão da câmera',
       camwall: 'Mural de câmeras',
       camwall: 'Mural de câmeras',
+      overlay: 'Sobreposição de streaming',
     },
     },
     title: 'Tokens da API de câmera',
     title: 'Tokens da API de câmera',
     navTitle: 'Tokens da API de câmera',
     navTitle: 'Tokens da API de câmera',
@@ -6681,6 +6684,8 @@ export default {
         'Um token de transmissão da câmera só consegue buscar transmissões e instantâneos. Use-o no Home Assistant, no Frigate ou em qualquer coisa que incorpore uma única câmera.',
         'Um token de transmissão da câmera só consegue buscar transmissões e instantâneos. Use-o no Home Assistant, no Frigate ou em qualquer coisa que incorpore uma única câmera.',
       hintCamWall:
       hintCamWall:
         'Um token do mural de câmeras abre /camwall em uma tela sem login. Ele vê o nome e o estado de cada impressora e as transmissões das câmeras. Não vê nomes de arquivo, endereços nem códigos de acesso.',
         'Um token do mural de câmeras abre /camwall em uma tela sem login. Ele vê o nome e o estado de cada impressora e as transmissões das câmeras. Não vê nomes de arquivo, endereços nem códigos de acesso.',
+      hintOverlay:
+        'Um token de sobreposição de streaming abre /overlay/{printerId} em uma tela sem login — para o OBS ou qualquer transmissão ao vivo. Ele vê a transmissão da câmera de uma impressora e seu status de impressão ao vivo, incluindo o nome de arquivo mostrado na tela. Não vê endereços nem códigos de acesso.',
       title: 'Criar novo token',
       title: 'Criar novo token',
       nameLabel: 'Nome do token',
       nameLabel: 'Nome do token',
       namePlaceholder: 'ex. Home Assistant',
       namePlaceholder: 'ex. Home Assistant',
@@ -6693,6 +6698,9 @@ export default {
       camWallUrlTitle: 'Endereço do mural de câmeras para esta tela',
       camWallUrlTitle: 'Endereço do mural de câmeras para esta tela',
       camWallUrlHint:
       camWallUrlHint:
         'Abra este endereço na tela. Qualquer pessoa que consiga lê-lo pode assistir ao painel, então trate-o como uma chave: revogue o token para cortar o acesso da tela.',
         'Abra este endereço na tela. Qualquer pessoa que consiga lê-lo pode assistir ao painel, então trate-o como uma chave: revogue o token para cortar o acesso da tela.',
+      overlayUrlTitle: 'Endereço de sobreposição para OBS',
+      overlayUrlHint:
+        'Adicione isto como Fonte de navegador no OBS. Altere o número em /overlay/1 para o número da sua impressora (do endereço dela na página Impressoras). Qualquer pessoa que consiga ler o endereço pode assistir à transmissão, então trate-o como uma chave: revogue o token para cortar o acesso.',
       title: 'Token criado – copie agora',
       title: 'Token criado – copie agora',
       warning:
       warning:
         'Esta é a única vez que este token será visível. Após fechar este diálogo, você nunca poderá vê-lo novamente.',
         'Esta é a única vez que este token será visível. Após fechar este diálogo, você nunca poderá vê-lo novamente.',

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

@@ -0,0 +1,6471 @@
+export default {
+  nav: {
+    printers: "Принтеры",
+    archives: "Архив",
+    queue: "Очередь печати",
+    stats: "Статистика",
+    profiles: "Профили",
+    maintenance: "Обслуживание",
+    projects: "Проекты",
+    inventory: "Филамент",
+    files: "Файловый менеджер",
+    makerworld: "MakerWorld",
+    notifications: "Уведомления",
+    settings: "Настройки",
+    system: "Система",
+    collapseSidebar: "Свернуть боковую панель",
+    expandSidebar: "Развернуть боковую панель",
+    update: "Обновить",
+    updateAvailable: "Доступно обновление: v{{version}}",
+    updateAvailableBanner: "Доступна версия {{version}}!",
+    viewUpdate: "Посмотреть обновление",
+    viewOnGithub: "Открыть на GitHub",
+    keyboardShortcuts: "Горячие клавиши (?)",
+    switchToLight: "Включить светлую тему",
+    switchToDark: "Включить тёмную тему",
+    switchToSystem: "Использовать системную тему",
+    smartSwitches: "Умные выключатели",
+    logout: "Выйти",
+    installApp: "Установить приложение",
+    installAppSuccess: "Bambuddy установлен",
+  },
+  common: {
+    save: "Сохранить",
+    saving: "Сохранение...",
+    cancel: "Отмена",
+    delete: "Удалить",
+    edit: "Изменить",
+    add: "Добавить",
+    close: "Закрыть",
+    confirm: "Подтвердить",
+    loading: "Загрузка...",
+    error: "Ошибка",
+    errorLoading: "Не удалось загрузить данные",
+    retry: "Повторить",
+    success: "Готово",
+    warning: "Предупреждение",
+    enabled: "Включено",
+    disabled: "Отключено",
+    yes: "Да",
+    no: "Нет",
+    on: "Вкл.",
+    off: "Выкл.",
+    all: "Все",
+    none: "Нет",
+    search: "Поиск",
+    filter: "Фильтр",
+    sort: "Сортировка",
+    refresh: "Обновить",
+    download: "Скачать",
+    upload: "Загрузить",
+    uploading: "Загрузка...",
+    uploadFailed: "Ошибка загрузки",
+    actions: "Действия",
+    status: "Состояние",
+    name: "Название",
+    description: "Описание",
+    date: "Дата",
+    time: "Время",
+    hours: "часов",
+    minutes: "минут",
+    seconds: "секунд",
+    days: "дней",
+    enable: "Включить",
+    disable: "Отключить",
+    permissions: "Разрешения",
+    noPrinters: "Принтеры не настроены",
+    noData: "Нет данных",
+    linkNotFound: "Ссылка не найдена",
+    required: "Обязательно",
+    optional: "Необязательно",
+    dismiss: "Закрыть",
+    apply: "Применить",
+    reset: "Сбросить",
+    export: "Экспорт",
+    import: "Импорт",
+    clear: "Очистить",
+    selectAll: "Выбрать все",
+    deselectAll: "Снять выделение",
+    noChange: "— Без изменений —",
+    unchanged: "Без изменений",
+    unassigned: "Не назначено",
+    unknown: "Неизвестно",
+    unknownError: "Неизвестная ошибка",
+    today: "Сегодня",
+    tomorrow: "Завтра",
+    asap: "Как можно скорее",
+    overdue: "Просрочено",
+    now: "Сейчас",
+    collapse: "Свернуть",
+    expand: "Развернуть",
+    previous: "Назад",
+    next: "Далее",
+    viewArchive: "Открыть архив",
+    viewInFileManager: "Открыть в файловом менеджере",
+    addedBy: "Добавил: {{username}}",
+    prints: "печатей",
+    more: "ещё +{{count}}",
+    ascending: "По возрастанию",
+    descending: "По убыванию",
+    back: "Назад",
+    copy: "Копировать",
+    copied: "Скопировано!",
+    printer: "Принтер",
+    remove: "Удалить",
+    type: "Тип",
+    print: "Печать",
+    rename: "Переименовать",
+    move: "Переместить",
+    create: "Создать",
+    duplicate: "Дублировать",
+    left: "Левый",
+    right: "Правый",
+  },
+  printers: {
+    title: "Принтеры",
+    addPrinter: "Добавить принтер",
+    addPreflight: {
+      checking: "Проверка подключения...",
+      warning: "Некоторые проверки подключения не пройдены. Принтер может отображаться как отключённый. Проверьте результаты ниже, исправьте возможные проблемы или сохраните настройки как есть.",
+      back: "Назад",
+      saveAnyway: "Всё равно сохранить",
+    },
+    editPrinter: "Изменить принтер",
+    deletePrinter: "Удалить принтер",
+    printerName: "Название принтера",
+    serialNumber: "Серийный номер",
+    ipAddress: "IP-адрес / имя хоста",
+    accessCode: "Код доступа",
+    model: "Модель",
+    nozzleCount: "Количество сопел",
+    autoArchive: "Автоматически архивировать",
+    status: {
+      available: "Доступен",
+      idle: "Ожидает",
+      printing: "Печать",
+      paused: "Приостановлен",
+      offline: "Не в сети",
+      problem: "Проблема",
+      error: "Ошибка",
+      finished: "Завершено",
+      unknown: "Неизвестно",
+    },
+    temperatures: {
+      nozzle: "Сопло",
+      bed: "Стол",
+      chamber: "Камера",
+    },
+    heaterHistory: {
+      title: "История нагрева",
+      openLabel: "Открыть историю нагрева",
+      nozzle: "Сопло",
+      nozzle2: "Сопло 2",
+      bed: "Стол",
+      chamber: "Камера",
+      error: "Не удалось загрузить историю",
+      empty: "Данные пока не записывались",
+    },
+    progress: "Выполнено {{percent}}%",
+    timeRemaining: "Осталось {{time}}",
+    deleteConfirm: "Удалить принтер «{{name}}»?",
+    maintenanceOk: "Обслуживание в норме",
+    maintenanceWarning: "{{count}} предупреждение",
+    maintenanceWarning_plural: "{{count}} предупреждений",
+    maintenanceDue: "Требуется: {{count}}",
+    maintenanceDue_plural: "Требуется: {{count}}",
+    sort: {
+      name: "Название",
+      status: "Состояние",
+      model: "Модель",
+      location: "Расположение",
+      eta: "Время завершения",
+      ascending: "По возрастанию",
+      descending: "По убыванию",
+    },
+    cardSize: {
+      small: "Маленькие карточки",
+      medium: "Средние карточки",
+      large: "Большие карточки",
+      extraLarge: "Очень большие карточки",
+    },
+    pageView: {
+      openCamWallPage: "Открыть стену камер как отдельную страницу",
+      cards: "Карточки",
+      camWall: "Стена камер",
+    },
+    camWall: {
+      page: {
+        tokenRejected: "Эта ссылка на стену камер больше недействительна. Возможно, срок действия токена истёк или он был отозван.",
+        loadFailed: "Не удалось загрузить принтеры.",
+      },
+      noPrinters: "Нет принтеров для отображения",
+      noSignal: "Нет сигнала",
+      live: "Онлайн",
+      snap: "Снимок",
+      off: "Выкл.",
+      summary: "Онлайн: {{live}}, снимки: {{snap}}, всего: {{total}}",
+      layer: "Слой {{cur}}/{{total}}",
+      timeLeft: "Осталось {{time}}",
+      statusMode: {
+        off: "Выкл.",
+        compact: "Компактный",
+        full: "Полный",
+      },
+      settings: {
+        title: "Настройки стены камер",
+        maxLive: "Максимум онлайн-трансляций",
+        maxLiveHint: "Сколько камер одновременно показывать в реальном времени. Остальные будут обновляться снимками.",
+        snapshotInterval: "Интервал снимков, с",
+        snapshotIntervalHint: "Как часто обновлять снимки камер без онлайн-трансляции.",
+        statusOverlay: "Наложение состояния",
+        statusOverlayHint: "Компактный режим: только состояние. Полный: прогресс, слой и оставшееся время.",
+      },
+    },
+    hideOffline: "Скрыть принтеры не в сети",
+    nextAvailable: "Ближайший доступный",
+    powerOn: "Включить питание",
+    offlinePrintersWithPlugs: "Принтеры не в сети с умными розетками",
+    noPrintersConfigured: "Принтеры пока не настроены",
+    search: "Поиск принтеров...",
+    noSearchResults: "Нет принтеров, соответствующих поиску или фильтрам",
+    filter: {
+      allStatuses: "Все состояния",
+      allLocations: "Все расположения",
+    },
+    toolbar: {
+      filters: "Фильтры",
+      view: "Вид",
+      actions: "Действия",
+    },
+    readyToPrint: "Готов к печати",
+    external: "Внешняя подача",
+    extL: "Внешняя, левое",
+    extR: "Внешняя, правое",
+    deleteArchives: "Удалить архивы печати",
+    noLabel: "Без метки",
+    printPreview: "Предпросмотр печати",
+    width: "Ширина",
+    height: "Высота",
+    noObjectsFound: "Объекты не найдены",
+    objectsLoadedOnPrintStart: "Объекты загрузятся после начала печати",
+    willBeSkipped: "Будет пропущен",
+    name: "Название",
+    serialCannotBeChanged: "Серийный номер нельзя изменить",
+    locationHelp: "Используется для группировки принтеров и фильтрации заданий в очереди",
+    wifiSignal: {
+      veryWeak: "Очень слабый",
+      weak: "Слабый",
+      fair: "Средний",
+      good: "Хороший",
+      excellent: "Отличный",
+    },
+    maintenanceUpToDate: "Обслуживание выполнено — нажмите для просмотра",
+    maintenance: {
+      title: "На обслуживании",
+      subtitle: "Принтер приостановлен: подключение отключено, задания из очереди не назначаются, уведомления не отправляются.",
+      pillLabel: "Обслуживание",
+      exitButton: "Завершить обслуживание",
+      menuEnter: "Перевести в режим обслуживания",
+      menuExit: "Выйти из режима обслуживания",
+      toastEntered: "Принтер {{name}} переведён в режим обслуживания",
+      toastExited: "Принтер {{name}} снова в работе",
+      confirmMidPrintTitle: "Перевести в режим обслуживания во время печати?",
+      confirmMidPrintMessage: "Принтер {{name}} сейчас печатает. Режим обслуживания отключит MQTT, отслеживание прогресса и уведомление о завершении этого задания. Продолжить?",
+      editFieldLabel: "Режим обслуживания",
+      editFieldHelp: "Отключает для принтера MQTT, назначение заданий из очереди и уведомления. Используйте при ремонте, параллельных установках Bambuddy или временной приостановке.",
+    },
+    chamberLightOn: "Включить подсветку камеры",
+    chamberLightOff: "Выключить подсветку камеры",
+    files: "Файлы",
+    browseFiles: "Открыть файлы принтера",
+    autoOffAfterPrint: "Автовыключение после печати",
+    autoOffExecuted: "Автовыключение выполнено — включите принтер для сброса состояния",
+    hmsErrors: "Ошибки HMS",
+    viewHmsErrors: "Показать ошибки HMS: {{count}}",
+    resume: "Продолжить",
+    pause: "Пауза",
+    stop: "Стоп",
+    camera: "Камера",
+    skipObject: "Пропустить объект",
+    reconnect: "Переподключить",
+    forceRefresh: "Принудительно обновить",
+    forceRefreshSuccess: "Запрошено обновление данных",
+    mqttDebug: "Отладка MQTT",
+    printerInformation: "Информация о принтере",
+    copyToClipboard: "Копировать",
+    copied: "Скопировано!",
+    state: "Состояние",
+    wifiSignalLabel: "Сигнал Wi‑Fi",
+    developerMode: "Режим разработчика",
+    enabled: "Включён",
+    disabled: "Отключён",
+    addedOn: "Добавлен",
+    sdCard: "SD-карта",
+    inserted: "Установлена",
+    notInserted: "Не установлена",
+    totalPrintHours: "Часов печати",
+    activeNozzle: "Активное сопло: {{nozzle}}",
+    nozzleRack: "Держатель сопел",
+    nozzleDocked: "В держателе",
+    nozzleMounted: "Установлено",
+    nozzleActive: "Активно",
+    nozzleIdle: "Ожидание",
+    nozzleDiameter: "Диаметр",
+    nozzleType: "Тип",
+    nozzleStatus: "Состояние",
+    nozzleFilament: "Филамент",
+    nozzleWear: "Износ",
+    nozzleMaxTemp: "Макс. температура",
+    nozzleSerial: "Серийный номер",
+    nozzleHardenedSteel: "Закалённая сталь",
+    nozzleStainlessSteel: "Нержавеющая сталь",
+    nozzleTungstenCarbide: "Карбид вольфрама",
+    nozzleFlow: "Производительность",
+    nozzleHighFlow: "Высокопоточное",
+    nozzleStandardFlow: "Стандартное",
+    firmwareUpdate: "Обновление прошивки",
+    firmwareInstructions: "На сенсорном экране принтера откройте",
+    firmwareNav: "Перейдите в раздел",
+    settings: "Настройки",
+    firmware: "Прошивка",
+    discoverPrinters: "Найти принтеры",
+    searching: "Поиск...",
+    manualEntry: "Добавить вручную",
+    addFromCloud: "Добавить из облака",
+    toast: {
+      printerDeleted: "Принтер удалён",
+      missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
+      printerAdded: "Принтер добавлен",
+      printerUpdated: "Настройки принтера обновлены",
+      failedToDelete: "Не удалось удалить принтер",
+      failedToAdd: "Не удалось добавить принтер",
+      connectionFailedNotAdded: "Не удалось подключиться к принтеру. Проверьте IP-адрес, серийный номер и код доступа, а также убедитесь, что включён режим «Только локальная сеть». Принтер не добавлен.",
+      failedToUpdate: "Не удалось обновить настройки принтера",
+      commandSent: "Команда отправлена",
+      failedToSendCommand: "Не удалось отправить команду",
+      turnedOn: "Питание {{name}} включено",
+      failedToPowerOn: "Не удалось включить {{name}}",
+      scriptTriggered: "Сценарий запущен",
+      printStopped: "Печать остановлена",
+      printPaused: "Печать приостановлена",
+      printResumed: "Печать продолжена",
+      referenceDeleted: "Эталон удалён",
+      detectionAreaSaved: "Область обнаружения сохранена",
+      failedToRunScript: "Не удалось запустить сценарий",
+      failedToStopPrint: "Не удалось остановить печать",
+      failedToPausePrint: "Не удалось приостановить печать",
+      failedToResumePrint: "Не удалось продолжить печать",
+      failedToControlChamberLight: "Не удалось изменить состояние подсветки камеры",
+      failedToSetSpeed: "Не удалось изменить скорость печати",
+      failedToUpdateSetting: "Не удалось изменить настройку",
+      failedToSkipObjects: "Не удалось пропустить объекты",
+      failedToRereadRfid: "Не удалось повторно считать RFID",
+      failedToCheckPlate: "Не удалось проверить пластину",
+      failedToUpdateLabel: "Не удалось обновить метку",
+      failedToDeleteReference: "Не удалось удалить эталон",
+      failedToSaveDetectionArea: "Не удалось сохранить область обнаружения",
+      plateCheckEnabled: "Проверка пластины включена",
+      plateCheckDisabled: "Проверка пластины отключена",
+      calibrationSaved: "Калибровка сохранена",
+      calibrationFailed: "Не удалось выполнить калибровку",
+      rfidRereadInitiated: "Запущено повторное считывание RFID",
+      loadInitiated: "Загрузка филамента…",
+      unloadInitiated: "Выгрузка филамента…",
+      failedToLoad: "Не удалось загрузить филамент",
+      failedToUnload: "Не удалось выгрузить филамент",
+    },
+    connection: {
+      connected: "Подключён",
+      offline: "Не в сети",
+    },
+    plateStatus: {
+      markCleared: "Отметить пластину как очищенную",
+      cleared: "Пластина очищена",
+      notCleared: "Пластина не очищена",
+      inUse: "Пластина используется",
+    },
+    queue: {
+      inQueue: "В очереди: {{count}} задание",
+      inQueue_plural: "В очереди: {{count}} заданий",
+    },
+    controls: "Управление",
+    rfid: {
+      reread: "Повторно считать RFID",
+    },
+    ams: {
+      load: "Загрузить",
+      unload: "Выгрузить",
+    },
+    bedJog: {
+      limitWarning: "При ручном перемещении ограничения хода не контролируются: из-за ошибки прошивки Bambu программные концевики игнорируются для удалённых команд. Перемещайте осторожно, чтобы избежать столкновения.",
+      title: "Ручное перемещение",
+      bed: "Стол",
+      step: "Шаг (мм)",
+      up: "Поднять стол",
+      down: "Опустить стол",
+      disabledWhilePrinting: "Недоступно во время печати",
+      notHomedTitle: "Принтер не выполнил парковку по осям",
+      notHomedMessage: "После последней печати принтер не выполнял Home. Сначала запустите автоматическую парковку для безопасного позиционирования (парковка печатающей головы и поиск нуля по X, Y и Z) либо продолжите перемещение — программные концевики будут отключены.",
+      homeZ: "Автопарковка",
+      moveAnyway: "Всё равно переместить",
+      homingStarted: "Выполняется автопарковка…",
+    },
+    permission: {
+      noAdd: "У вас нет разрешения на добавление принтеров",
+      noEdit: "У вас нет разрешения на изменение принтеров",
+      noDelete: "У вас нет разрешения на удаление принтеров",
+      noControl: "У вас нет разрешения на управление принтерами",
+      noFiles: "У вас нет разрешения на доступ к файлам принтера",
+      noAmsRfid: "У вас нет разрешения на повторное считывание RFID AMS",
+      noSmartPlugControl: "У вас нет разрешения на управление умными розетками",
+      noCamera: "У вас нет разрешения на просмотр камер",
+    },
+    modal: {
+      addTitle: "Добавить принтер",
+      editTitle: "Изменить принтер",
+      myPrinter: "Мой принтер",
+      selectModel: "Выберите модель…",
+      locationGroup: "Расположение / группа (необязательно)",
+      locationPlaceholder: "например: Мастерская, Офис, Подвал",
+      autoArchiveLabel: "Автоматически архивировать завершённые печати",
+      fromPrinterSettings: "Из настроек принтера",
+      modelOptional: "Модель (необязательно)",
+      saveChanges: "Сохранить изменения",
+    },
+    skipObjects: {
+      tooltip: "Пропустить объекты",
+      onlyWhilePrinting: "Пропуск объектов доступен только во время печати",
+      requiresMultiple: "Для пропуска требуется не менее двух объектов",
+      title: "Пропустить объекты",
+      matchIdsInfo: "Сопоставьте идентификаторы с экраном принтера",
+      printerShowsIds: "На экране принтера отображаются идентификаторы объектов на пластине",
+      skipSelected: "Пропустить выбранные",
+      skipping: "Пропуск…",
+      noObjectsSelected: "Объекты не выбраны",
+      selectObjectsToSkip: "Выберите объекты, которые нужно исключить из текущей печати",
+      skipped: "пропущен",
+      objectsSkipped: "Объекты пропущены",
+      activeCount: "Активно: {{count}}",
+      waitForLayer: "Пропуск объектов доступен со второго слоя (сейчас слой {{layer}})",
+      skip: "Пропустить",
+      confirmTitle: "Пропустить объект?",
+      confirmMessage: "Пропустить объект «{{name}}»? Это действие нельзя отменить.",
+    },
+    confirm: {
+      deleteTitle: "Удалить принтер",
+      deleteMessage: "Удалить принтер «{{name}}»? Все параметры подключения будут удалены.",
+      deleteArchivesNote: "Вся история печати этого принтера будет удалена безвозвратно.",
+      keepArchivesNote: "История печати сохранится, но больше не будет связана с этим принтером.",
+      stopTitle: "Остановить печать",
+      stopMessage: "Остановить текущую печать на принтере «{{name}}»? Задание будет отменено.",
+      stopButton: "Остановить печать",
+      pauseTitle: "Приостановить печать",
+      pauseMessage: "Приостановить текущую печать на принтере «{{name}}»?",
+      pauseButton: "Приостановить печать",
+      resumeTitle: "Продолжить печать",
+      resumeMessage: "Продолжить печать на принтере «{{name}}»?",
+      resumeButton: "Продолжить печать",
+      powerOnTitle: "Включить принтер",
+      powerOnMessage: "Включить питание принтера «{{name}}»?",
+      powerOnButton: "Включить питание",
+      powerOffTitle: "Выключить принтер",
+      powerOffMessage: "Выключить питание принтера «{{name}}»?",
+      powerOffWarning: "ВНИМАНИЕ: принтер «{{name}}» сейчас печатает. Выключение питания прервёт печать и может повредить принтер. Продолжить?",
+      powerOffButton: "Выключить питание",
+      haToggleTitle: "Переключить «{{name}}»",
+      haToggleMessage: "Переключить сущность Home Assistant {{entity}}? Если она сейчас включена, питание может отключиться.",
+      haToggleWarning: "ВНИМАНИЕ: принтер «{{name}}» сейчас печатает. Переключение {{entity}} может отключить питание и прервать печать. Продолжить?",
+      haToggleButton: "Переключить",
+    },
+    bulk: {
+      select: "Выбрать",
+      selectAll: "Выбрать все",
+      selectByLocation: "Выбрать по расположению",
+      selected: "Выбрано: {{count}}",
+      actions: {
+        stop: "Стоп",
+        pause: "Пауза",
+        resume: "Продолжить",
+        clearPlate: "Освободить стол",
+        clearHMS: "Очистить уведомления",
+      },
+      confirm: {
+        stopTitle: "Остановить печати: {{count}}",
+        stopMessage: "Активные печати на {{count}} принтерах будут отменены. Это действие нельзя отменить.",
+        stopButton: "Остановить все",
+        pauseTitle: "Приостановить печати: {{count}}",
+        pauseMessage: "Активные печати на {{count}} принтерах будут приостановлены.",
+        pauseButton: "Приостановить все",
+        clearPlateTitle: "Освободить столы: {{count}}",
+        clearPlateMessage: "Столы на {{count}} принтерах будут отмечены как свободные; после этого могут запуститься задания из очереди.",
+        clearPlateButton: "Освободить все",
+      },
+      success: "Действие «{{action}}» выполнено на {{count}} принтерах",
+      partial: "Успешно: {{succeeded}}, с ошибкой: {{failed}}",
+      noneApplicable: "Ни один из выбранных принтеров не находится в подходящем состоянии",
+      selectByState: "Выбрать по состоянию",
+    },
+    discovery: {
+      title: "Поиск принтеров",
+      searching: "Поиск…",
+      scanning: "Сканирование…",
+      scanProgress: "Сканирование: {{scanned}}/{{total}}",
+      foundPrinters: "Найдено принтеров: {{count}}",
+      noPrintersFound: "Принтеры не найдены",
+      noPrintersFoundSubnet: "В указанной подсети принтеры не найдены.",
+      noPrintersFoundNetwork: "Принтеры в сети не найдены.",
+      allConfigured: "Все найденные принтеры уже настроены.",
+      alreadyAdded: "Уже добавлен",
+      select: "Выбрать",
+      manualEntry: "Добавить вручную",
+      addFromCloud: "Добавить из облака",
+      subnetToScan: "Подсеть для сканирования",
+      dockerNote: "Обнаружен Docker. Укажите подсеть принтера в формате CIDR. В docker-compose.yml требуется network_mode: host.",
+      scanSubnet: "Сканировать подсеть",
+      discoverNetwork: "Найти принтеры в сети",
+      scanningSubnet: "Поиск принтеров Bambu в подсети…",
+      scanningNetwork: "Сканирование сети…",
+      serialRequired: "Требуется серийный номер",
+      unknown: "Неизвестно",
+      failedToStart: "Не удалось запустить поиск",
+      customSubnetOption: "Другая подсеть…",
+      customSubnetLabel: "Другая подсеть (CIDR)",
+      customSubnetNote: "Используйте другую подсеть, если принтер находится в иной сети. Порты FTP (990) и MQTT (8883) должны быть доступны через маршрутизатор.",
+    },
+    drying: {
+      start: "Начать сушку",
+      stop: "Остановить сушку",
+      temperature: "Температура",
+      duration: "Продолжительность",
+      hours: "ч",
+      timeRemaining: "осталось {{time}}",
+      active: "Сушка",
+      targetSummary: "{{filament}} при {{temp}} °C",
+      notSupported: "Сушка не поддерживается",
+      powerRequired: "Подключите адаптер питания AMS, чтобы включить сушку",
+      startingDrying: "Запуск сушки...",
+      toastCommandSent: "Команда сушки отправлена",
+      toastStopped: "Сушка остановлена",
+      toastNotStarted: "Принтер принял команду, но AMS не начал сушку. Убедитесь, что адаптер питания AMS подключён, а принтер не занят.",
+      screenOnly: "На этом принтере управлять сушкой AMS можно только с экрана самого принтера (ограничение Bambu)",
+      stoppingDrying: "Остановка сушки...",
+      rotateTray: "Вращать катушку во время сушки",
+      rotateUnavailableReason: "Недоступно: слот этого AMS сейчас подаёт филамент в печатающую головку. Катушка зафиксирована подающей трубкой и не может вращаться. Сначала выгрузите филамент.",
+    },
+    amsBackup: {
+      titleOn: "Резервный филамент AMS включён. Нажмите, чтобы отключить.",
+      titleOff: "Резервный филамент AMS выключен. Нажмите, чтобы включить.",
+      titleUnknown: "Состояние резервного филамента AMS недоступно для этого принтера.",
+      toastEnabled: "Резервный филамент AMS включён",
+      toastDisabled: "Резервный филамент AMS отключён",
+      modalTitle: "Резервный филамент AMS",
+      modalHelp: "Когда активный слот опустеет, принтер по очереди использует подходящие слоты с тем же профилем и цветом в указанном порядке.",
+      modalNoSlots: "Филамент не загружен.",
+      modalNoPairs: "Резервных пар нет: ни у двух слотов не совпадают профиль филамента и цвет.",
+      extruderRightShort: "П",
+      extruderLeftShort: "Л",
+      stateOn: "Включено",
+      stateOff: "Отключено",
+      stateUnknown: "Не поддерживается этим принтером",
+    },
+    activeJobSlot: {
+      title: "Этот слот используется как филамент {{n}} в текущей печати",
+      ariaLabel: "Слот {{n}} текущей печати",
+    },
+    expectedSlot: {
+      title: "Принтер ожидает филамент в этом слоте",
+      ariaLabel: "Ожидаемый слот филамента {{n}}",
+      label: "{{ams}} · Слот {{slot}}",
+      external: "Внешняя катушка",
+    },
+    filaments: "Филаменты",
+    openCameraOverlay: "Открыть камеру поверх интерфейса",
+    openCameraWindow: "Открыть камеру в новом окне",
+    firmwareUpdateAvailable: "Доступно обновление прошивки: {{current}} → {{latest}}",
+    firmwareUpToDate: "Прошивка {{version}} — актуальная версия",
+    firmwareUpdateButton: "Обновить",
+    plateDetection: {
+      noPermission: "У вас нет прав на изменение настроек принтера",
+      enabledClick: "Проверка пластины включена — нажмите, чтобы отключить",
+      disabledClick: "Проверка пластины отключена — нажмите, чтобы включить",
+      manageCalibration: "Управление калибровкой проверки пластины",
+      calibrationRequired: "Требуется калибровка",
+      calibrationInstructions: "Убедитесь, что печатная пластина <strong>полностью пустая</strong>, затем нажмите «Калибровать».",
+      calibrationDescription: "При калибровке сохраняется эталонное изображение пустой пластины. При последующих проверках система сравнивает снимок с эталоном и обнаруживает посторонние объекты.",
+      calibrationTip: "<strong>Совет:</strong> можно сохранить до 5 калибровок для разных пластин. При проверке система автоматически выберет наиболее подходящий эталон.",
+      plateEmpty: "Пластина выглядит пустой",
+      objectsDetected: "На пластине обнаружены объекты",
+      confidence: "Уверенность",
+      difference: "Различие",
+      analysisPreview: "Предпросмотр анализа:",
+      analysisLegend: "Зелёная рамка — область обнаружения, красная заливка — отличия от калибровочного снимка",
+      savedReferences: "Сохранённые эталоны ({{count}}/{{max}})",
+      deleteReference: "Удалить эталон",
+      labelPlaceholder: "Метка...",
+      clickToEdit: "{{label}} — нажмите для изменения",
+      clickToAddLabel: "Нажмите, чтобы добавить метку",
+    },
+    speed: {
+      title: "Скорость печати",
+      silent: "Тихий (50%)",
+      standard: "Стандартный (100%)",
+      sport: "Спортивный (124%)",
+      ludicrous: "Безумный (166%)",
+    },
+    airduct: {
+      title: "Режим воздуховода",
+      cooling: "Охлаждение",
+      heating: "Нагрев",
+    },
+    noSdCard: "Нет SD-карты",
+    door: {
+      open: "Открыта",
+      closed: "Закрыта",
+    },
+    fans: {
+      partCooling: "Вентилятор обдува модели",
+      auxiliary: "Дополнительный вентилятор",
+      chamber: "Вентилятор камеры",
+    },
+    clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS",
+    estimatedCompletion: "Расчётное время завершения",
+    plateNumber: "Пластина {{number}}",
+    slotOptions: "Параметры слота",
+    amsPopup: {
+      friendlyName: "Имя AMS",
+      friendlyNamePlaceholder: "Например, левый AMS",
+      serialNumber: "Серийный номер",
+      firmwareVersion: "Прошивка",
+      save: "Сохранить",
+      clear: "Очистить",
+      noEditPermission: "У вас нет прав на переименование модулей AMS",
+    },
+    firmwareModal: {
+      title: "Обновление прошивки",
+      titleUpToDate: "Сведения о прошивке",
+      currentVersion: "Текущая:",
+      latestVersion: "Последняя:",
+      releaseNotes: "Примечания к выпуску",
+      checkingPrereqs: "Проверка условий...",
+      sdCardReady: "SD-карта готова. Нажмите ниже, чтобы загрузить прошивку.",
+      uploadedSuccess: "Прошивка загружена на SD-карту!",
+      applyInstructions: "Чтобы установить обновление на принтер:",
+      step1: "На экране принтера откройте <strong>Настройки</strong>",
+      step2: "Перейдите в раздел <strong>Прошивка</strong>",
+      step3: "Выберите <strong>Обновить с SD-карты</strong>",
+      step4: "Обновление займёт 10–20 минут",
+      done: "Готово",
+      starting: "Запуск...",
+      uploadFirmware: "Загрузить прошивку",
+      uploadFailed: "Не удалось начать загрузку: {{error}}",
+      uploadedToast: "Прошивка загружена. Запустите обновление с экрана принтера.",
+      availableVersions: "Доступные версии",
+      usable: "Доступна",
+      unavailable: "Недоступна",
+      installed: "Установлена",
+      newerBadge: "новее",
+      olderBadge: "старее",
+      currentBadge: "текущая",
+    },
+    accessCodePlaceholder: "Оставьте пустым, чтобы сохранить текущий",
+    roi: {
+      title: "Область обнаружения (ROI)",
+      xStart: "Начало X",
+      yStart: "Начало Y",
+      width: "Ширина",
+      height: "Высота",
+      instruction: "Настройте область обнаружения так, чтобы она охватывала печатную пластину. Зелёная рамка в предпросмотре показывает текущую область.",
+    },
+    developerModeWarning: "Режим LAN для разработчиков не включён на: {{names}}. Некоторые функции могут не работать.",
+    howToEnable: "Как включить",
+    incompatibleFile: "Файл подготовлен для {{slicedFor}}, а выбранный принтер — {{printerModel}}",
+    dropNotPrintable: "Для печати подходят только файлы .gcode и .gcode.3mf",
+    dropToPrint: "Перетащите файл для печати",
+    cannotPrint: "Принтер занят",
+  },
+  archives: {
+    title: "Архив печати",
+    no3mfBanner: {
+      title: "Некоторые последние задания сохранены без миниатюр",
+      body: "Слайсер не оставил файл .gcode.3mf на SD-карте принтера, поэтому Bambuddy не смог получить миниатюру и метаданные слайсера. Обычно это происходит, если в слайсере отключена настройка «Сохранять отправленные файлы во внешнем хранилище» (вкладка «Устройство» в Bambu Studio / OrcaSlicer).",
+      docsLink: "См. шаг 4 установки",
+      dismissLabel: "Закрыть это уведомление",
+    },
+    searchPlaceholder: "Поиск в архиве...",
+    filterByPrinter: "Фильтр по принтеру",
+    filterByStatus: "Фильтр по состоянию",
+    sortBy: "Сортировать по",
+    sortNewest: "Сначала новые",
+    sortOldest: "Сначала старые",
+    sortName: "Названию",
+    sortDuration: "Длительности",
+    sortLargest: "Сначала большие",
+    sortSmallest: "Сначала маленькие",
+    sortSize: "Размеру",
+    noArchives: "Архивы не найдены",
+    noArchivesSearch: "По вашему запросу ничего не найдено",
+    originalPrintNotVisible: "Исходная печать не отображается — попробуйте сбросить фильтры",
+    noArchivesYet: "Архив пока пуст",
+    prints: "печатей",
+    pagination: {
+      showing: "Показано",
+      to: "–",
+      of: "из",
+      show: "Показывать",
+      page: "Страница",
+      all: "Все",
+    },
+    loadingArchives: "Загрузка архива...",
+    releaseToUpload: "Отпустите файлы для загрузки",
+    showAll: "Показать все",
+    showFavoritesOnly: "Только избранное",
+    gridView: "Плитка",
+    listView: "Список",
+    calendarView: "Календарь",
+    logView: "Журнал печати",
+    manageTags: "Управление тегами",
+    showFailedPrints: "Показывать неудачные печати",
+    hideFailedPrints: "Скрывать неудачные печати",
+    hideDuplicates: "Скрывать дубликаты",
+    viewOriginalPrint: "Открыть исходную печать №{{id}}",
+    printTime: "Время печати",
+    filamentUsed: "Израсходовано филамента",
+    cost: "Стоимость",
+    preview: "Предпросмотр",
+    deleteArchive: "Удалить запись из архива",
+    deleteConfirm: "Удалить эту запись из архива?",
+    favorite: "В избранное",
+    unfavorite: "Удалить из избранного",
+    viewDetails: "Подробнее",
+    status: {
+      completed: "Завершено",
+      failed: "Ошибка",
+      stopped: "Остановлено",
+    },
+    toast: {
+      source3mfAttached: "Исходный 3MF прикреплён: {{filename}}",
+      failedUploadSource3mf: "Не удалось загрузить исходный 3MF",
+      source3mfRemoved: "Исходный 3MF удалён",
+      failedRemoveSource3mf: "Не удалось удалить исходный 3MF",
+      f3dAttached: "F3D прикреплён: {{filename}}",
+      failedUploadF3d: "Не удалось загрузить F3D",
+      f3dRemoved: "F3D удалён",
+      failedRemoveF3d: "Не удалось удалить F3D",
+      timelapseAttached: "Таймлапс прикреплён: {{filename}}",
+      timelapseAlreadyAttached: "Таймлапс уже прикреплён",
+      noMatchingTimelapse: "Подходящий таймлапс не найден",
+      failedScanTimelapse: "Не удалось найти таймлапс",
+      failedAttachTimelapse: "Не удалось прикрепить таймлапс",
+      timelapseRemoved: "Таймлапс удалён",
+      failedRemoveTimelapse: "Не удалось удалить таймлапс",
+      timelapseUploaded: "Таймлапс загружен: {{filename}}",
+      failedUploadTimelapse: "Не удалось загрузить таймлапс",
+      archiveDeleted: "Запись удалена из архива",
+      failedDeleteArchive: "Не удалось удалить запись из архива",
+      addedToFavorites: "Добавлено в избранное",
+      removedFromFavorites: "Удалено из избранного",
+      projectUpdated: "Проект обновлён",
+      failedUpdateProject: "Не удалось обновить проект",
+      linkCopied: "Ссылка скопирована",
+      failedCopyLink: "Не удалось скопировать ссылку",
+      photoDeleted: "Фотография удалена",
+      failedDeletePhoto: "Не удалось удалить фотографию",
+      failedDeleteArchives: "Не удалось удалить записи из архива",
+      failedUpdateFavorites: "Не удалось обновить избранное",
+      exportDownloaded: "Экспортированный файл скачан",
+      exportFailed: "Ошибка экспорта",
+    },
+    menu: {
+      print: "Печать",
+      openInBambuStudio: "Открыть в слайсере",
+      slice: "Нарезать",
+      externalLink: "Внешняя ссылка",
+      viewOnMakerWorld: "Открыть на MakerWorld",
+      preview3d: "3D-просмотр",
+      viewTimelapse: "Посмотреть таймлапс",
+      scanForTimelapse: "Найти таймлапс",
+      uploadTimelapse: "Загрузить таймлапс",
+      removeTimelapse: "Удалить таймлапс",
+      downloadSource3mf: "Скачать исходный 3MF",
+      uploadSource3mf: "Загрузить исходный 3MF",
+      replaceSource3mf: "Заменить исходный 3MF",
+      removeSource3mf: "Удалить исходный 3MF",
+      uploadF3d: "Загрузить F3D",
+      replaceF3d: "Заменить F3D",
+      downloadF3d: "Скачать F3D",
+      removeF3d: "Удалить F3D",
+      download: "Скачать",
+      copyDownloadLink: "Скопировать ссылку для скачивания",
+      qrCode: "QR-код",
+      viewPhotos: "Посмотреть фотографии",
+      viewPhotosCount: "Фотографии ({{count}})",
+      projectPage: "Страница проекта",
+      addToFavorites: "Добавить в избранное",
+      removeFromFavorites: "Удалить из избранного",
+      edit: "Изменить",
+      printLog: "Журнал печати",
+      goToProject: "Открыть проект: {{name}}",
+      addToProject: "Добавить в проект",
+      removeFromProject: "Удалить из проекта",
+      loading: "Загрузка...",
+      noProjectsAvailable: "Нет доступных проектов",
+      searchProjects: "Поиск проектов…",
+      select: "Выбрать",
+      deselect: "Снять выделение",
+      delete: "Удалить",
+    },
+    permission: {
+      noReprint: "У вас нет разрешения на повторную печать этой записи",
+      noAddToQueue: "У вас нет разрешения на добавление в очередь",
+      noUpdateArchives: "У вас нет разрешения на изменение архива",
+      noUploadFiles: "У вас нет разрешения на загрузку файлов",
+      noDownload: "У вас нет разрешения на скачивание файлов из архива",
+      noCopyLink: "У вас нет разрешения на копирование ссылок для скачивания",
+      noDelete: "У вас нет разрешения на удаление этой записи",
+      noEdit: "У вас нет разрешения на изменение этой записи",
+      noCreate: "У вас нет разрешения на создание записей в архиве",
+    },
+    platePicker: {
+      title: "Выберите пластину для предпросмотра",
+      hint: "В этой записи несколько пластин. Выберите пластину, которую нужно открыть в просмотрщике G-code.",
+      plateLabel: "Пластина {{index}}",
+      objectCount: "{{count}} объект",
+      objectCount_plural: "{{count}} объектов",
+      noGcode: "В этой записи нет нарезанного G-code для предпросмотра. Сначала откройте файл в Bambu Studio и выполните нарезку.",
+    },
+    card: {
+      previousPlate: "Предыдущая пластина",
+      nextPlate: "Следующая пластина",
+      plateNumber: "Пластина {{index}}",
+      moreOptions: "Щёлкните правой кнопкой для дополнительных действий",
+      addToFavorites: "Добавить в избранное",
+      removeFromFavorites: "Удалить из избранного",
+      cancelled: "отменено",
+      failed: "ошибка",
+      duplicate: "дубликат",
+      duplicateTitle: "Эта модель уже печаталась",
+      openSource3mf: "Открыть исходный 3MF в Bambu Studio (щёлкните правой кнопкой для дополнительных действий)",
+      downloadF3d: "Скачать файл проекта Fusion 360",
+      viewTimelapse: "Посмотреть таймлапс",
+      viewPhoto: "Посмотреть фотографию",
+      viewPhotos: "Посмотреть фотографии: {{count}}",
+      openFolder: "Открыть папку: {{name}}",
+      slicedFile: "Нарезанный файл — готов к печати",
+      sourceFile: "Только исходный файл — сопоставление AMS недоступно",
+      gcode: "G-CODE",
+      source: "ИСХОДНИК",
+      project: "Проект: {{name}}",
+      runsBadge: "Печатей: {{count}}",
+      runsBadgeTitle: "Всего печатей: {{count}}, успешных: {{successful}}, неудачных: {{failed}}. Нажмите, чтобы открыть полный журнал.",
+      estimated: "Расчётное время: {{time}}",
+      actual: "Фактическое время: {{time}}",
+      accuracy: "Точность оценки: {{percent}}%",
+      filament: "{{weight}} г",
+      layer: "{{count}} слой",
+      layers: "{{count}} слоёв",
+      object: "{{count}} объект",
+      objects: "{{count}} объектов",
+      slicedFor: "Нарезано для {{model}}",
+      uploadedBy: "Загрузил",
+      noPermissionReprint: "У вас нет разрешения на повторную печать",
+      noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
+      noPermissionEdit: "У вас нет разрешения на изменение архива",
+      noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
+      openInBambuStudio: "Открыть в слайсере",
+      openInBambuStudioToSlice: "Открыть в слайсере для нарезки",
+      slice: "Нарезать",
+      externalLink: "Внешняя ссылка",
+      makerWorld: "MakerWorld: {{designer}}",
+      viewProject: "Открыть проект",
+      noExternalLink: "Внешняя ссылка не указана",
+      preview3d: "3D-просмотр",
+      download: "Скачать",
+      edit: "Изменить",
+      delete: "Удалить",
+    },
+    runLog: {
+      title: "Журнал печати",
+      modalTitle: "Журнал печати — {{name}}",
+      modalTitleFallback: "эта запись",
+      empty: "Для этой записи пока нет событий печати.",
+      col: {
+        date: "Дата",
+        status: "Состояние",
+        duration: "Длительность",
+        filament: "Филамент",
+        cost: "Стоимость",
+      },
+      status: {
+        completed: "Завершено",
+        failed: "Ошибка",
+        cancelled: "Отменено",
+        stopped: "Остановлено",
+        skipped: "Пропущено",
+        printing: "Печать",
+      },
+    },
+    modal: {
+      deleteArchive: "Удаление записи из архива",
+      deleteConfirm: "Удалить «{{name}}»? Это действие нельзя отменить.",
+      deleteButton: "Удалить",
+      deletePurgeStats: "Также исключить эту печать из краткой статистики: филамент, время, стоимость и энергия",
+      deleteQueueItemsWarning: "Также будут удалены связанные элементы очереди: {{count}}.",
+      deleteBlockedByPrinting: "Удаление невозможно: сейчас печатаются связанные элементы очереди ({{count}}). Сначала остановите печать.",
+      removeSource3mf: "Удаление исходного 3MF",
+      removeSource3mfConfirm: "Удалить исходный файл 3MF из «{{name}}»? Исходный проект слайсера будет удалён.",
+      removeButton: "Удалить",
+      removeF3d: "Удаление F3D",
+      removeF3dConfirm: "Удалить файл проекта Fusion 360 из «{{name}}»?",
+      removeTimelapse: "Удаление таймлапса",
+      removeTimelapseConfirm: "Удалить видео таймлапса из «{{name}}»?",
+      timelapse: "{{name}} — таймлапс",
+      selectTimelapse: "Выбор таймлапса",
+      selectTimelapseDesc: "Автоматически найти совпадение не удалось. Выберите таймлапс для этой печати:",
+      deleteArchives: "Удаление записей из архива",
+      deleteArchivesConfirm: "Удалить записи из архива ({{count}})? Это действие нельзя отменить.",
+      deleteCount: "Удалить: {{count}}",
+    },
+    page: {
+      title: "Архив",
+      printsCount: "Показано {{filtered}} из {{total}} печатей",
+      dropFilesHere: "Перетащите сюда файлы .3mf",
+      releaseToUpload: "Отпустите файлы для загрузки",
+      only3mfSupported: "Поддерживаются только файлы .3mf",
+      close: "Закрыть",
+      selected: "Выбрано: {{count}}",
+      selectAll: "Выбрать все",
+      tags: "Теги",
+      project: "Проект",
+      favorite: "Избранное",
+      delete: "Удалить",
+      toggledFavorites: "Избранное изменено для записей: {{count}}",
+      failedUpdateFavorites: "Не удалось обновить избранное",
+      archivesDeleted: "Удалено записей: {{count}}",
+      failedDeleteArchives: "Не удалось удалить записи из архива",
+      photoDeleted: "Фотография удалена",
+      failedDeletePhoto: "Не удалось удалить фотографию",
+    },
+    list: {
+      name: "Название",
+      printer: "Принтер",
+      date: "Дата",
+      size: "Размер",
+      actions: "Действия",
+      hasTimelapse: "Есть таймлапс",
+    },
+    log: {
+      date: "Дата",
+      printName: "Название печати",
+      printer: "Принтер",
+      user: "Пользователь",
+      status: "Состояние",
+      duration: "Длительность",
+      filament: "Филамент",
+      allPrinters: "Все принтеры",
+      allUsers: "Все пользователи",
+      allStatuses: "Все состояния",
+      cancelled: "Отменено",
+      skipped: "Пропущено",
+      dateFrom: "С",
+      dateTo: "По",
+      noEntries: "Записи журнала не найдены",
+      showing: "Показано {{count}} из {{total}} записей",
+      rowsPerPage: "Строк",
+      page: "Страница",
+      prev: "Назад",
+      next: "Далее",
+      clearLog: "Очистить журнал",
+      clearLogTitle: "Очистка журнала печати",
+      clearLogConfirm: "Все записи журнала печати будут удалены без возможности восстановления. Архив и очередь не изменятся. Продолжить?",
+      clearLogButton: "Очистить всё",
+      cleared: "Удалено записей журнала: {{count}}",
+      clearFailed: "Не удалось очистить журнал печати",
+      deleteEntryTitle: "Удаление записи журнала",
+      deleteEntryConfirm: "Запись будет удалена из журнала, а её вклад в расход филамента, время и стоимость исчезнет из краткой статистики. Связанная запись архива, если она существует, не изменится. Это действие нельзя отменить.",
+      entryDeleted: "Запись журнала удалена",
+      entryDeleteFailed: "Не удалось удалить запись журнала",
+      editEntryTitle: "Изменение записи журнала",
+      editEntryDescription: "Укажите результат этой печати. Виджет анализа отказов группирует данные по этим значениям, поэтому изменения сразу отразятся в статистике.",
+      entryUpdated: "Запись журнала обновлена",
+      entryUpdateFailed: "Не удалось обновить запись журнала",
+      statuses: {
+        completed: "Завершено",
+        failed: "Ошибка",
+        stopped: "Остановлено",
+        cancelled: "Отменено",
+        skipped: "Пропущено",
+      },
+    },
+  },
+  dispatchToast: {
+    untitled: "Задание печати",
+    startingPrints: "Запуск печати",
+    progressSummary: "Завершено: {{complete}}/{{total}} • Обрабатывается: {{processing}}",
+    expandDetails: "Показать подробности отправки",
+    collapseDetails: "Скрыть подробности отправки",
+    awaitingPrinter: "Ожидание принтера…",
+    status: {
+      processing: "Обработка",
+      completed: "Завершено",
+      failed: "Ошибка",
+    },
+    failed: {
+      generic: "Не удалось отправить задание",
+      upload_failed: "Не удалось загрузить файл на принтер",
+      start_command_failed: "Принтер отклонил команду запуска",
+    },
+    dismiss: "Закрыть",
+  },
+  pipelineRuns: {
+    title: "Запуски конвейеров",
+    loading: "Загрузка…",
+    empty: "Конвейеры ещё не запускались.",
+    filter: {
+      pipeline: "Конвейер",
+      status: "Состояние",
+      target: "Назначение",
+      all: "Все",
+      allPipelines: "Все конвейеры",
+      allStatus: "Все состояния",
+      allTargets: "Все назначения",
+      clear: "Сбросить фильтры",
+      noMatches: "Нет запусков, соответствующих выбранным фильтрам.",
+    },
+    totalCount_one: "{{n}} запуск",
+    totalCount_other: "{{n}} запусков",
+    copies: "Копий: {{n}}",
+    failedCount: "Ошибок: {{n}}",
+    copyN: "Копия {{n}}",
+    retryFailed: "Повторить неудачные",
+    retryOf: "повтор запуска №{{n}}",
+    pagination: "{{start}}–{{end}} из {{total}}",
+    cancelledByUser: "Отменено пользователем",
+    toast: {
+      cancelled: "Запуск отменён",
+      cancelFailed: "Не удалось отменить запуск",
+      retryStarted: "Повторный запуск начат",
+      retryFailed: "Повторный запуск не удался",
+      cleared: "Удалено запусков: {{n}}",
+      clearFailed: "Не удалось очистить журнал",
+    },
+    clearLog: "Очистить журнал",
+    clearConfirmTitle: "Очистить журнал?",
+    clearConfirmBody: "Удалить все завершённые, неудачные, отменённые и частично неудачные запуски конвейеров? Выполняющиеся запуски сохранятся. Это действие нельзя отменить.",
+    clearConfirmAction: "Очистить",
+    jobStatus: {
+      pending: "ожидание",
+      awaiting_printer: "ожидание принтера",
+      queued: "в очереди",
+      printing: "печать",
+      completed: "завершено",
+      failed: "ошибка",
+      cancelled: "отменено",
+    },
+  },
+  queue: {
+    title: "Очередь печати",
+    subtitle: "Планирование и управление заданиями печати",
+    filamentShort: {
+      rowBadge: "Для назначенной катушки недостаточно филамента",
+      rowTooltip: "Планировщик отметил это задание. Нажмите «Запустить», чтобы увидеть нехватку по каждому слоту и решить, печатать ли всё равно.",
+      confirmTitle: "Недостаточно филамента",
+      confirmIntro: "Назначенной катушки недостаточно как минимум для одного слота. Всё равно запустить печать?",
+      lineItem: "Слот {{slot}}: требуется {{required}} г, осталось {{remaining}} г",
+      unknown: "неизвестно",
+      printAnyway: "Всё равно печатать",
+    },
+    editQueueItem: "Изменить задание в очереди",
+    selectAllPlates: "Выбрать все пластины ({{count}})",
+    deselectAll: "Снять выделение",
+    printQueued: "Задание добавлено в очередь",
+    printQueuedWillStartWhenIdle: "Запустится, когда принтер освободится",
+    itemsQueued: "Добавлено в очередь: {{count}}",
+    sending: "Отправка...",
+    sendingProgress: "Отправка {{current}} из {{total}}...",
+    adding: "Добавление...",
+    addingProgress: "Добавление {{current}} из {{total}}...",
+    savingProgress: "Сохранение {{current}} из {{total}}...",
+    clearQueue: "Очистить очередь",
+    clearHistory: "Очистить историю",
+    emptyQueue: "Очередь пуста",
+    position: "Позиция",
+    scheduledTime: "Запланированное время",
+    moveUp: "Переместить вверх",
+    moveDown: "Переместить вниз",
+    startNow: "Запустить сейчас",
+    printingInProgress: "Идёт печать...",
+    viewArchive: "Открыть архив",
+    viewInFileManager: "Открыть в файловом менеджере",
+    itemCount: "{{count}} задание",
+    itemCount_plural: "{{count}} заданий",
+    dragToReorder: "Перетащите для изменения порядка (только задания «как можно скорее»)",
+    reorderHint: "Позиция влияет только на задания «как можно скорее». Запланированные задания запускаются в указанное время.",
+    sjf: {
+      label: "SJF",
+      tooltip: "Shortest Job First — планировщик отдаёт приоритет более коротким заданиям",
+    },
+    addedBy: "Добавил: {{name}}",
+    nextInQueue: "Следующее в очереди",
+    clearPlateSuccess: "Пластина очищена — можно запускать следующую печать",
+    plateNumber: "Пластина {{index}}",
+    quantity: "Количество",
+    quantityHint: "Будет создано заданий: {{count}}",
+    activeBatches: "Активные партии",
+    batchProgress: "Выполнено {{completed}} из {{total}}",
+    cancelBatch: "Отменить оставшиеся",
+    batchCancelled: "Оставшиеся задания партии отменены",
+    cancelBatchConfirmTitle: "Отменить партию",
+    cancelBatchConfirmMessage: "Отменить все оставшиеся задания этой партии?",
+    batch: {
+      defaultName: "Партия",
+      label: "{{count}} задание",
+      label_plural: "{{count}} заданий",
+      pendingCount: "Ожидает: {{count}}",
+      pendingCount_plural: "Ожидают: {{count}}",
+      expand: "Развернуть партию",
+      collapse: "Свернуть партию",
+      groupAsBatch: "Объединить в партию…",
+      groupAsBatchDescription: "Объединить выбранные задания ({{count}}) в одну сворачиваемую партию.",
+      nameLabel: "Название партии",
+      namePlaceholder: "Например: подарки к пятнице",
+      create: "Создать партию",
+      ungroup: "Разгруппировать",
+      ungroupConfirmTitle: "Разгруппировать партию?",
+      ungroupConfirmMessage: "Задания останутся в очереди, но больше не будут объединены в одну группу.",
+      dragGroup: "Перетащить группу",
+    },
+    tabs: {
+      queue: "Очередь",
+      history: "История",
+      timeline: "Временная шкала",
+      pipelines: "Конвейеры",
+    },
+    layout: {
+      flatList: "Список",
+      byPrinter: "По принтерам",
+      groupByPrinter: "Группировать по принтерам",
+    },
+    history: {
+      emptyTitle: "История пока пуста",
+      emptyDescription: "Здесь появятся завершённые, отменённые и неудачные задания.",
+    },
+    dragGhost: {
+      multiCount: "{{count}} заданий",
+      batch: "{{name}} ({{count}} копия)",
+      batch_plural: "{{name}} ({{count}} копий)",
+    },
+    sections: {
+      currentlyPrinting: "Сейчас печатается",
+      queued: "В очереди",
+      history: "История",
+    },
+    status: {
+      pending: "Ожидает",
+      waiting: "Ожидание",
+      printing: "Печать",
+      paused: "Приостановлено",
+      completed: "Завершено",
+      failed: "Ошибка",
+      skipped: "Пропущено",
+      cancelled: "Отменено",
+    },
+    summary: {
+      printing: "Печатается",
+      queued: "В очереди",
+      totalTime: "Общее время очереди",
+      totalWeight: "Общий вес очереди",
+      history: "История",
+    },
+    filter: {
+      allPrinters: "Все принтеры",
+      unassigned: "Не назначено",
+      allStatus: "Все состояния",
+      allLocations: "Все расположения",
+      any: "Любое",
+    },
+    sort: {
+      byPosition: "По позиции",
+      byName: "По названию",
+      byPrinter: "По принтеру",
+      bySchedule: "По времени запуска",
+      byDate: "По дате",
+      ascendingOldest: "По возрастанию (сначала старые)",
+      descendingNewest: "По убыванию (сначала новые)",
+    },
+    badges: {
+      staged: "Подготовлено",
+      requiresPrevious: "Требуется успешное предыдущее задание",
+      autoPowerOff: "Автоотключение питания",
+      gcodeInjection: "G-code",
+    },
+    empty: {
+      title: "Нет запланированных заданий",
+      description: "Запланируйте печать на странице архива через пункт «Запланировать» в контекстном меню либо перетащите сюда файлы.",
+    },
+    time: {
+      asap: "Как можно скорее",
+      overdue: "Просрочено",
+      now: "Сейчас",
+      lessThanMinute: "Меньше чем через минуту",
+      inMinutes: "Через {{count}} мин",
+      inHours: "Через {{count}} ч",
+    },
+    actions: {
+      startPrint: "Запустить печать",
+      stopPrint: "Остановить печать",
+      requeue: "Вернуть в очередь",
+    },
+    bulkEdit: {
+      title: "Изменить {{count}} задание",
+      title_plural: "Изменить задания: {{count}}",
+      description: "К выбранным заданиям будут применены только изменённые параметры.",
+      printer: "Принтер",
+      noChange: "— Без изменений —",
+      queueOptions: "Параметры очереди",
+      staged: "Подготовлено (ручной запуск)",
+      autoPowerOff: "Отключить питание после печати",
+      requirePrevious: "Требовать успешного завершения предыдущего задания",
+      printOptions: "Параметры печати",
+      bedLevelling: "Калибровка стола",
+      flowCalibration: "Калибровка потока",
+      vibrationCalibration: "Калибровка вибраций",
+      layerInspection: "Проверка первого слоя",
+      timelapse: "Таймлапс",
+      useAms: "Использовать AMS",
+      nozzleOffsetCali: "Калибровка смещения сопел",
+      applyChanges: "Применить изменения",
+      selectAll: "Выбрать все",
+      deselectAll: "Снять выделение",
+      selected: "Выбрано: {{count}}",
+      editSelected: "Изменить выбранные",
+      cancelSelected: "Отменить выбранные",
+    },
+    confirm: {
+      cancelTitle: "Отменить запланированную печать",
+      cancelMessage: "Отменить задание «{{name}}»?",
+      stopTitle: "Остановить печать",
+      stopMessage: "Остановить текущую печать «{{name}}»? Задание на принтере будет отменено.",
+      removeTitle: "Удалить из истории",
+      removeMessage: "Удалить «{{name}}» из истории очереди?",
+      clearHistoryTitle: "Очистить историю",
+      clearHistoryMessage: "Удалить все записи из истории ({{count}})?",
+      cancelButton: "Отменить печать",
+      stopButton: "Остановить печать",
+      thisPrint: "эту печать",
+      thisItem: "это задание",
+    },
+    toast: {
+      cancelled: "Задание отменено",
+      cancelFailed: "Не удалось отменить задание",
+      removed: "Задание удалено",
+      removeFailed: "Не удалось удалить задание",
+      stopped: "Печать остановлена",
+      stopFailed: "Не удалось остановить печать",
+      released: "Задание передано в очередь",
+      startFailed: "Не удалось запустить печать",
+      reorderFailed: "Не удалось изменить порядок очереди",
+      historyCleared: "Удалено записей истории: {{count}}",
+      clearHistoryFailed: "Не удалось очистить историю",
+      updateFailed: "Не удалось обновить задания",
+      bulkCancelled: "Отменено заданий: {{count}}",
+      bulkCancelFailed: "Не удалось отменить задания",
+      batchCreated: "Партия «{{name}}» создана",
+      batchCreateFailed: "Не удалось создать партию",
+      batchUngrouped: "Разгруппировано заданий: {{count}}",
+      batchUngroupFailed: "Не удалось разгруппировать партию",
+      resumedAfterFailure: "Очередь возобновлена — восстановлено заданий: {{restored}}",
+      resumeAfterFailureFailed: "Не удалось возобновить очередь",
+    },
+    resumeAfterFailure: {
+      banner: "Очередь принтера {{printer}} заблокирована из-за ошибки предыдущей печати — пропущено заданий: {{count}}",
+      bannerHint: "Устраните проблему с принтером, затем возобновите очередь, чтобы вернуть пропущенные задания и снять блокировку.",
+      button: "Возобновить после ошибки",
+      confirmTitle: "Возобновить очередь после ошибки?",
+      confirmMessage: "Вернуть пропущенные задания ({{count}}) для принтера {{printer}} в состояние ожидания и снять блокировку. Перед продолжением убедитесь, что принтер готов.",
+    },
+    timeline: {
+      listView: "Список",
+      timelineView: "Временная шкала",
+      unassigned: "Не назначено",
+      noData: "На этот день печать не запланирована",
+      nothingCommitted: "В этом интервале нет заданий с фиксированным временем. Подготовленные и ожидающие задания, а также задания «как можно скорее» на свободных принтерах не показываются — задайте время запуска или выпустите подготовленное задание в очередь.",
+      allDoneBy: "Расчётное завершение всех заданий: {{time}}",
+      staged: "Подготовлено",
+      filterAll: "Показать все",
+      filterPrinting: "Печатается",
+      filterQueued: "В очереди",
+      time: {
+        anyMoment: "в любой момент",
+        minutesLeft: "осталось {{minutes}} мин",
+        hoursLeft: "осталось {{hours}} ч",
+        hoursMinutesLeft: "осталось {{hours}} ч {{minutes}} мин",
+      },
+      day: {
+        previous: "Предыдущий день",
+        next: "Следующий день",
+        today: "Сегодня",
+      },
+      window: {
+        back12h: "Назад на 12 часов",
+        forward12h: "Вперёд на 12 часов",
+        now: "Сейчас",
+      },
+      printerColumnHeader: "Принтер",
+    },
+    permissions: {
+      noStopPrint: "У вас нет разрешения останавливать печать",
+      noStartPrint: "У вас нет разрешения запускать печать",
+      noEdit: "У вас нет разрешения изменять это задание",
+      noCancel: "У вас нет разрешения отменять это задание",
+      noRequeue: "У вас нет разрешения возвращать задания в очередь",
+      noRemove: "У вас нет разрешения удалять это задание",
+      noClearHistory: "У вас нет разрешения очищать историю",
+      noEditItems: "У вас нет разрешения изменять задания очереди",
+      noCancelItems: "У вас нет разрешения отменять задания очереди",
+    },
+  },
+  stats: {
+    title: "Статистика",
+    subtitle: "Перетаскивайте виджеты для изменения порядка. Нажмите значок глаза, чтобы скрыть виджет.",
+    overview: "Обзор",
+    totalPrints: "Всего печатей",
+    successRate: "Успешность",
+    totalPrintTime: "Общее время печати",
+    printTime: "Время печати",
+    totalFilament: "Всего использовано филамента",
+    filamentUsed: "Использовано филамента",
+    filamentCost: "Стоимость филамента",
+    totalCost: "Общая стоимость",
+    energyUsed: "Потреблено энергии",
+    energyCost: "Стоимость энергии",
+    energyWarmingUpTooltip: "Учёт энергии ещё накапливает почасовые снимки. Итоги за выбранный период станут точными, когда появится хотя бы один снимок до начала этого периода. Первые значения могут быть занижены.",
+    averagePrintTime: "Среднее время печати",
+    printsPerDay: "Печатей в день",
+    byPrinter: "По принтерам",
+    printsByPrinter: "Печати по принтерам",
+    byMaterial: "По материалам",
+    byMonth: "По месяцам",
+    last7Days: "Последние 7 дней",
+    last30Days: "Последние 30 дней",
+    last90Days: "Последние 90 дней",
+    allTime: "За всё время",
+    quickStats: "Краткая статистика",
+    printActivity: "Активность печати",
+    filamentTypes: "Типы филамента",
+    filamentTrends: "Расход филамента",
+    failureAnalysis: "Анализ неудач",
+    timeAccuracy: "Точность оценки времени",
+    successful: "Успешно:",
+    failed: "Неудачно:",
+    cancelled: "Отменено:",
+    perfectEstimate: "100% — точное совпадение с оценкой",
+    noTimeAccuracyData: "Данных о точности оценки времени пока нет",
+    noFilamentData: "Нет данных о филаменте",
+    noPrinterData: "Нет данных о принтерах",
+    noPrintData: "Нет данных о печати",
+    noPrintDataLast30Days: "За последние 30 дней данных о печати нет",
+    failureReasons: "Причины неудач",
+    topFailureReasons: "Основные причины неудач",
+    failedPrintsCount: "Неудачных печатей: {{failed}} из {{total}}",
+    lastWeekRate: "За прошлую неделю: {{rate}}%",
+    resetLayout: "Сбросить расположение",
+    recalculateCosts: "Пересчитать стоимость",
+    recalculateCostsHint: "Пересчитать стоимость всех архивных заданий по текущим ценам филамента",
+    exportStats: "Экспорт статистики",
+    exportAsCsv: "Экспорт в CSV",
+    exportAsExcel: "Экспорт в Excel",
+    hiddenCount: "Скрыто: {{count}}",
+    exportDownloaded: "Файл экспорта скачан",
+    exportFailed: "Не удалось экспортировать данные",
+    layoutReset: "Расположение сброшено",
+    recalculatedCosts: "Пересчитана стоимость архивов: {{count}}",
+    recalculateFailed: "Не удалось пересчитать стоимость",
+    loadingStats: "Загрузка статистики...",
+    noPermissionResetLayout: "У вас нет разрешения сбрасывать расположение",
+    noPermissionRecalculate: "У вас нет разрешения пересчитывать стоимость",
+    noPrintDataInRange: "В выбранном периоде нет данных о печати",
+    periodFilament: "Филамент за период",
+    periodCost: "Стоимость за период",
+    avgPerPrint: "В среднем на печать",
+    usageOverTime: "Расход по времени",
+    filamentByWeight: "По весу",
+    printDuration: "Продолжительность печати",
+    printerUtilization: "Загрузка принтеров",
+    filamentSuccess: "Успешность по материалам",
+    printHabits: "Характер печати",
+    printTimeOfDay: "Время суток",
+    colorDistribution: "Распределение по цветам",
+    noColorData: "Нет данных о цветах",
+    records: "Рекорды",
+    longestPrint: "Самая долгая печать",
+    heaviestPrint: "Самая тяжёлая печать",
+    mostExpensivePrint: "Самая дорогая печать",
+    busiestDay: "Самый загруженный день",
+    successStreak: "Серия успешных печатей",
+    streakPrint: "успешная печать подряд",
+    streakPrints: "{{count}} успешных печатей подряд",
+    printerStats: "Статистика принтеров",
+    hours: "часов",
+    avgPrints: "В среднем печатей",
+    noArchiveData: "Нет данных о печати",
+    filamentByTime: "По времени",
+    avgWeight: "Средний вес",
+    avgTime: "Среднее время",
+    filamentByPrints: "По количеству печатей",
+    timeframe: {
+      today: "Сегодня",
+      "this-week": "Эта неделя",
+      "this-month": "Этот месяц",
+      "last-7": "Последние 7 дней",
+      "last-30": "Последние 30 дней",
+      "last-90": "Последние 90 дней",
+      "this-year": "Этот год",
+      "all-time": "За всё время",
+      custom: "Произвольный период",
+      from: "С",
+      to: "По",
+    },
+    allUsers: "Все пользователи",
+    noUser: "Без пользователя (система)",
+    filterByUser: "Фильтр по пользователю",
+  },
+  maintenance: {
+    title: "Обслуживание",
+    overview: "Обзор",
+    allOk: "Всё обслуживание выполнено вовремя",
+    dueCount: "Требуется выполнить: {{count}}",
+    dueCount_plural: "Требуется выполнить: {{count}}",
+    warningCount: "Предупреждений: {{count}}",
+    warningCount_plural: "Предупреждений: {{count}}",
+    totalPrintTime: "Общее время печати",
+    nextMaintenance: "Следующее обслуживание",
+    nothingDue: "Пока ничего не требуется",
+    tasks: "Задачи",
+    lastPerformed: "Последнее выполнение",
+    interval: "Интервал",
+    hoursRemaining: "Осталось {{hours}} ч",
+    hoursOverdue: "Просрочено на {{hours}} ч",
+    markDone: "Отметить выполненным",
+    performMaintenance: "Выполнить обслуживание",
+    history: "История",
+    noHistory: "История обслуживания пуста",
+    editPrintHours: "Изменить часы печати",
+    currentHours: "Текущие часы",
+    statusTab: "Состояние",
+    settingsTab: "Настройки",
+    overdueCount: "Просрочено: {{count}}",
+    dueSoonCount: "Скоро потребуется: {{count}}",
+    dueSoon: "Скоро потребуется",
+    allGood: "Всё в порядке",
+    overdueBy: "Просрочено на {{duration}}",
+    dueIn: "Через {{duration}}",
+    timeLeft: "Осталось {{duration}}",
+    day: "1 день",
+    days: "{{count}} дней",
+    week: "1 неделя",
+    weeks: "{{count}} недель",
+    month: "1 месяц",
+    months: "{{count}} месяцев",
+    year: "1 год",
+    maintenanceTypes: "Виды обслуживания",
+    maintenanceTypesDescription: "Системные и пользовательские задачи обслуживания",
+    addCustomType: "Добавить пользовательскую задачу",
+    restoreDefaults: "Восстановить стандартные задачи",
+    intervalType: "Тип интервала",
+    intervalValue: "Интервал ({{type}})",
+    icon: "Значок",
+    documentationLink: "Ссылка на документацию (необязательно)",
+    assignToPrinters: "Назначить принтерам",
+    selectAtLeastOnePrinter: "Выберите хотя бы один принтер",
+    addType: "Добавить вид",
+    custom: "Пользовательский",
+    printHours: "Часы печати",
+    calendarDays: "Календарные дни",
+    exampleName: "Например: заменить HEPA-фильтр",
+    viewDocumentation: "Открыть документацию",
+    timeBasedInterval: "Интервал по времени",
+    intervalOverrides: "Переопределение интервалов",
+    intervalOverridesDescription: "Настройка отдельных интервалов для конкретных принтеров",
+    assignedToPrinters: "Назначено принтерам:",
+    noPrintersAssigned: "Не назначено ни одному принтеру",
+    addPrinterShort: "Добавить:",
+    printersAssignedClick: "Назначено принтерам: {{count}} — нажмите для управления",
+    removeFromPrinter: "Убрать с этого принтера",
+    types: {
+      lubricateCarbonRods: "Смазать карбоновые валы",
+      lubricateRails: "Смазать линейные направляющие",
+      cleanNozzle: "Очистить сопло/хотэнд",
+      checkBelts: "Проверить натяжение ремней",
+      cleanBuildPlate: "Очистить печатную пластину",
+      checkExtruder: "Проверить шестерни экструдера",
+      checkCooling: "Проверить вентиляторы охлаждения",
+      generalInspection: "Общий осмотр",
+      cleanCarbonRods: "Очистить карбоновые валы",
+      lubricateSteelRods: "Смазать стальные валы",
+      cleanSteelRods: "Очистить стальные валы",
+      cleanLinearRails: "Очистить линейные направляющие",
+      checkPtfeTube: "Проверить PTFE-трубку",
+      replaceHepaFilter: "Заменить HEPA-фильтр",
+      replaceCarbonFilter: "Заменить угольный фильтр",
+      lubricateLeftNozzleRail: "Смазать направляющую левого сопла",
+    },
+    maintenanceComplete: "Обслуживание отмечено выполненным",
+    typeUpdated: "Вид обслуживания обновлён",
+    typeDeleted: "Вид обслуживания удалён",
+    defaultsRestored: "Восстановлено стандартных задач: {{count}}",
+    printHoursUpdated: "Часы печати обновлены",
+    printerAssigned: "Принтер назначен",
+    printerRemoved: "Принтер удалён из назначения",
+    deleteTypeConfirm: "Удалить «{{name}}»?",
+    deleteSystemTypeTitle: "Удалить стандартную задачу обслуживания?",
+    deleteSystemTypeMessage: "Удалить стандартную задачу обслуживания «{{name}}»?",
+    noPermissionUpdate: "У вас нет разрешения обновлять задачи обслуживания",
+    noPermissionPerform: "У вас нет разрешения отмечать обслуживание выполненным",
+    noPermissionEditTypes: "У вас нет разрешения изменять виды обслуживания",
+    noPermissionDeleteTypes: "У вас нет разрешения удалять виды обслуживания",
+    noPermissionEditHours: "У вас нет разрешения изменять часы печати",
+    noPermissionRemovePrinter: "У вас нет разрешения удалять назначения принтеров",
+    noPermissionAssignPrinter: "У вас нет разрешения назначать принтеры",
+    noPermissionEditIntervals: "У вас нет разрешения изменять интервалы",
+    configureSettings: "Настроить виды обслуживания и интервалы",
+  },
+  settings: {
+    title: "Настройки",
+    general: "Общие",
+    tabs: {
+      general: "Общие",
+      smartPlugs: "Умные розетки",
+      notifications: "Уведомления",
+      queue: "Рабочий процесс",
+      queueDispatch: "Очередь и отправка",
+      queuePipelines: "Конвейеры",
+      filament: "Филамент",
+      network: "Сеть",
+      apiKeys: "Ключи API",
+      virtualPrinter: "Виртуальный принтер",
+      spoolbuddy: "SpoolBuddy",
+      failureDetection: "Обнаружение сбоев",
+      users: "Аутентификация",
+      backup: "Резервное копирование",
+      emailAuth: "Аутентификация по email",
+      ldap: "LDAP",
+      twoFa: "Двухфакторная аутентификация",
+      oidc: "SSO / OIDC",
+      security: "Безопасность",
+    },
+    spoolbuddy: {
+      infoTitle: "Устройства SpoolBuddy",
+      infoBody: "Киоски SpoolBuddy регистрируются автоматически по сигналам активности. Удалите регистрацию устройства, если оно больше не используется или после сбоя демона остался устаревший дубликат.",
+      duplicatesTitle: "Зарегистрировано устройств: {{count}}",
+      duplicatesBody: "Интерфейс киоска использует только первое зарегистрированное устройство. Если один из экземпляров — устаревший дубликат после сбоя, удалите его регистрацию: работающее устройство зарегистрируется снова при следующем сигнале активности.",
+      empty: "Устройства SpoolBuddy пока не зарегистрированы.",
+      online: "В сети",
+      offline: "Не в сети",
+      unregister: "Удалить регистрацию",
+      unregisterSuccess: "Регистрация устройства удалена",
+      unregisterError: "Не удалось удалить регистрацию устройства",
+      confirmTitle: "Удалить регистрацию устройства SpoolBuddy?",
+      confirmBody: "Устройство «{{hostname}}» ({{deviceId}}) будет удалено из базы данных. Если оно работает, то зарегистрируется снова при следующем сигнале активности.",
+      ipAddress: "IP-адрес",
+      firmware: "Прошивка",
+      lastSeen: "Последняя активность",
+      daemonUptime: "Время работы демона",
+      systemUptime: "Время работы системы",
+      never: "никогда",
+      nfc: "NFC",
+      scale: "Весы",
+      cpuTemp: "Температура ЦП",
+      cpuLoad: "Загрузка ЦП",
+      memory: "Память",
+      disk: "Диск",
+      update: "Обновить",
+      updateConfirmTitle: "Обновить демон SpoolBuddy?",
+      updateConfirmBody: "Запустить обновление ПО на «{{hostname}}»? После установки обновления демон будет перезапущен.",
+      restartBrowser: "Перезапустить браузер",
+      restartBrowserConfirmTitle: "Перезапустить браузер киоска?",
+      restartBrowserConfirmBody: "Перезапустить браузер киоска на «{{hostname}}»? Экран ненадолго погаснет.",
+      restartDaemon: "Перезапустить демон",
+      restartDaemonConfirmTitle: "Перезапустить демон SpoolBuddy?",
+      restartDaemonConfirmBody: "Перезапустить демон SpoolBuddy на «{{hostname}}»? Устройство на несколько секунд пропадёт из сети.",
+      reboot: "Перезагрузить",
+      rebootConfirmTitle: "Перезагрузить устройство?",
+      rebootConfirmBody: "Перезагрузить «{{hostname}}»? Устройство будет недоступно около минуты.",
+      shutdown: "Выключить",
+      shutdownConfirmTitle: "Выключить устройство?",
+      shutdownConfirmBody: "Выключить «{{hostname}}»? Для повторного включения потребуется физический доступ.",
+      commandConfirm: "Подтвердить",
+      commandQueued: "Команда поставлена в очередь",
+      commandError: "Не удалось отправить команду",
+    },
+    ldap: {
+      title: "Аутентификация LDAP",
+      enabledDesc: "Аутентификация LDAP включена",
+      disabledDesc: "Аутентификация LDAP отключена",
+      disabledHint: "Настройте и сохраните параметры LDAP ниже, затем включите аутентификацию.",
+      enabled: "Аутентификация LDAP включена",
+      disabled: "Аутентификация LDAP отключена",
+      feature1: "Пользователи могут входить с учётными данными LDAP",
+      feature2: "Локальная учётная запись администратора остаётся резервным способом входа",
+      feature3: "При входе группы LDAP сопоставляются с группами Bambuddy",
+      serverConfig: "Настройка сервера LDAP",
+      serverUrl: "Адрес сервера",
+      serverUrlHint: "Используйте ldaps:// для SSL либо ldap:// с StartTLS",
+      security: "Безопасность",
+      securityHint: "StartTLS переводит обычное соединение на TLS. LDAPS использует TLS с самого начала.",
+      bindDn: "Bind DN (служебная учётная запись)",
+      bindPassword: "Пароль Bind",
+      searchBase: "Базовый DN поиска",
+      userFilter: "Фильтр поиска пользователей",
+      userFilterHint: "{username} заменяется именем пользователя при входе. Для OpenLDAP используйте (uid={username}).",
+      advanced: "Дополнительно",
+      autoProvision: "Автоматически создавать пользователей",
+      autoProvisionHint: "Автоматически создавать учётную запись Bambuddy при первом входе через LDAP",
+      defaultGroup: "Группа по умолчанию",
+      defaultGroupNone: "— Нет (без резервной группы) —",
+      defaultGroupHint: "Резервная группа для пользователя LDAP, который успешно вошёл, но не состоит ни в одной сопоставленной группе LDAP. Оставьте пустым, чтобы такие пользователи не получали разрешений.",
+      groupMapping: "Сопоставление групп (JSON)",
+      groupMappingHint: "Сопоставьте DN групп LDAP с группами Bambuddy. Доступные группы: ",
+      testConnection: "Проверить подключение",
+      settingsSaved: "Настройки LDAP сохранены",
+      errors: {
+        serverRequired: "Укажите адрес сервера LDAP",
+        searchBaseRequired: "Укажите базовый DN поиска",
+        enableAuthFirst: "Сначала включите аутентификацию",
+        configureLdapFirst: "Сначала сохраните настройки LDAP",
+      },
+    },
+    email: {
+      smtpSettings: "Настройка SMTP",
+      smtpHost: "SMTP-сервер",
+      smtpPort: "Порт SMTP",
+      security: "Безопасность",
+      authentication: "Аутентификация",
+      username: "Имя пользователя",
+      password: "Пароль",
+      fromEmail: "Адрес отправителя",
+      fromName: "Имя отправителя",
+      testConnection: "Проверить подключение SMTP",
+      testRecipient: "Адрес получателя тестового письма",
+      sendTest: "Отправить тестовое письмо",
+      sending: "Отправка...",
+      save: "Сохранить настройки",
+      saving: "Сохранение...",
+      advancedAuth: "Расширенная аутентификация",
+      advancedAuthEnabled: "Расширенная аутентификация включена",
+      advancedAuthEnabledDesc: "Функции управления пользователями через email активны. Новые пользователи получают автоматически созданные пароли по почте и могут сбрасывать пароль через функцию восстановления.",
+      advancedAuthDisabled: "Расширенная аутентификация отключена",
+      advancedAuthDisabledDesc: "Включите расширенную аутентификацию, чтобы использовать почтовые функции управления пользователями.",
+      enable: "Включить",
+      disable: "Отключить",
+      feature1: "Пароли для новых пользователей создаются автоматически и отправляются по email",
+      feature2: "Пользователи могут входить по имени или адресу электронной почты",
+      feature3: "Доступно восстановление забытого пароля",
+      feature4: "Администраторы могут сбрасывать пароли пользователей по email",
+      errors: {
+        requiredFields: "Заполните все обязательные поля",
+        usernameRequired: "При включённой аутентификации требуется имя пользователя",
+        enterTestEmail: "Введите адрес для тестового письма",
+        smtpServerAndEmail: "Перед проверкой укажите SMTP-сервер и адрес отправителя",
+        usernamePasswordRequired: "При включённой аутентификации требуются имя пользователя и пароль",
+        configureSmtpFirst: "Сначала настройте и проверьте SMTP",
+        enableAuthFirst: "Чтобы использовать почтовые функции, сначала включите аутентификацию.",
+      },
+      success: {
+        settingsSaved: "Настройки SMTP сохранены",
+      },
+      securityOptions: {
+        starttls: "STARTTLS (порт 587)",
+        ssl: "SSL/TLS (порт 465)",
+        none: "Без шифрования (порт 25)",
+      },
+      authOptions: {
+        enabled: "Включена",
+        disabled: "Отключена",
+      },
+    },
+    appearance: "Внешний вид",
+    notifications: "Уведомления",
+    smartPlugs: "Умные розетки",
+    spoolman: "Spoolman",
+    updates: "Обновления",
+    language: "Язык",
+    languageDescription: "Выберите предпочитаемый язык",
+    theme: "Тема",
+    themeLight: "Светлая",
+    themeDark: "Тёмная",
+    themeSystem: "Системная",
+    defaultView: "Страница по умолчанию",
+    defaultViewDescription: "Страница, которая открывается при запуске приложения",
+    checkForUpdates: "Проверить обновления",
+    autoUpdate: "Автоматическое обновление",
+    currentVersion: "Текущая версия",
+    latestVersion: "Последняя версия",
+    upToDate: "Установлена последняя версия",
+    updateAvailable: "Доступно обновление",
+    notificationLanguage: "Язык уведомлений",
+    notificationLanguageDescription: "Язык push-уведомлений",
+    bedCooledThreshold: "Порог остывания стола",
+    bedCooledThresholdDescription: "Температура, ниже которой стол после печати считается остывшим",
+    userNotificationsEnabled: "Пользовательские уведомления",
+    userNotificationsEnabledDescription: "Включить меню пользовательских и почтовых уведомлений о событиях печати. Требуется расширенная аутентификация.",
+    userNotificationsDisabledHint: "Для пользовательских уведомлений включите расширенную аутентификацию.",
+    notificationProviders: "Провайдеры уведомлений",
+    addProvider: "Добавить провайдера",
+    editProvider: "Изменить провайдера",
+    providerType: "Тип провайдера",
+    testNotification: "Проверить уведомление",
+    testSuccess: "Тестовое уведомление успешно отправлено",
+    testFailed: "Не удалось отправить тестовое уведомление",
+    quietHours: "Тихие часы",
+    quietHoursDescription: "Не беспокоить в указанный период",
+    quietHoursStart: "Начало",
+    quietHoursEnd: "Окончание",
+    events: {
+      title: "События уведомлений",
+      printStart: "Печать началась",
+      printComplete: "Печать завершена",
+      printFailed: "Ошибка печати",
+      printStopped: "Печать остановлена",
+      printProgress: "Этапы выполнения",
+      printProgressDescription: "Уведомлять при 25%, 50% и 75%",
+      printerOffline: "Принтер не в сети",
+      printerError: "Ошибка принтера",
+      filamentLow: "Филамент заканчивается",
+      maintenanceDue: "Требуется обслуживание",
+      maintenanceDueDescription: "Уведомлять, когда наступил срок обслуживания",
+    },
+    smartPlug: {
+      title: "Умные розетки",
+      add: "Добавить умную розетку",
+      edit: "Изменить умную розетку",
+      name: "Название",
+      ipAddress: "IP-адрес",
+      linkedPrinter: "Связанный принтер",
+      autoOn: "Автоматическое включение",
+      autoOnDescription: "Включать при начале печати",
+      autoOff: "Автоматическое выключение",
+      autoOffDescription: "Выключать после завершения печати",
+      offDelay: "Задержка выключения",
+      offDelayMinutes: "Минут после печати",
+      offDelayTemp: "Когда сопло остынет ниже заданной температуры",
+      currentState: "Текущее состояние",
+      turnOn: "Включить",
+      turnOff: "Выключить",
+    },
+    filamentTracking: "Учёт филамента",
+    filamentTrackingDesc: "Выберите способ учёта катушек: встроенный инвентарь или внешний сервер Spoolman.",
+    autoAddUnknownRfid: "Автоматически добавлять неизвестные RFID-катушки",
+    autoAddUnknownRfidDesc: "Автоматически создавать запись в инвентаре при обнаружении катушки с неизвестной RFID-меткой. Отключите, если регистрируете новые катушки заранее, чтобы избежать дубликатов.",
+    filamentChecks: "Проверки филамента",
+    disableFilamentWarnings: "Отключить предупреждения о филаменте",
+    disableFilamentWarningsDesc: "Не показывать предупреждения о нехватке филамента при запуске печати или добавлении в очередь",
+    preferLowestFilament: "Предпочитать катушку с наименьшим остатком",
+    preferLowestFilamentDesc: "Если подходят несколько катушек, использовать катушку с наименьшим остатком филамента",
+    preferLowestFilamentBackupNote: "Работает только при включённом AMS Filament Backup: иначе принтер не сможет переключиться на вторую катушку после окончания выбранной.",
+    trackingModeBuiltIn: "Встроенный инвентарь",
+    trackingModeBuiltInDesc: "Автоматическое сопоставление RFID и учёт расхода",
+    trackingModeSpoolmanDesc: "Внешний сервер управления филаментом",
+    builtInFeatureRfid: "Автоматически определяет официальные RFID-катушки Bambu Lab в AMS",
+    builtInFeatureUsage: "Учитывает расход филамента для каждой печати",
+    builtInFeatureCatalog: "Управление катушками, цветами и профилями K-фактора",
+    builtInFeatureThirdParty: "Сторонние катушки можно связать с катушками инвентаря",
+    amsSyncButton: "Синхронизировать вес из AMS",
+    amsSyncTitle: "Синхронизация веса катушек из AMS",
+    amsSyncMessage: "Вес всех катушек инвентаря будет заменён текущими значениями остатка из AMS подключённых принтеров. Используйте это для восстановления повреждённых данных о весе. Принтеры должны быть в сети.",
+    amsSyncing: "Синхронизация...",
+    amsSyncSuccess: "Синхронизировано катушек: {{synced}}, пропущено: {{skipped}}",
+    amsSyncError: "Не удалось синхронизировать вес из AMS",
+    spoolmanAmsSyncButton: "Синхронизировать вес Spoolman из AMS",
+    spoolmanAmsSyncTitle: "Синхронизация веса катушек Spoolman из AMS",
+    spoolmanAmsSyncMessage: "Вес всех катушек Spoolman будет обновлён по текущим значениям остатка из AMS подключённых принтеров. Принтеры должны быть в сети.",
+    spoolmanAmsSyncing: "Синхронизация...",
+    spoolmanAmsSyncSuccess: "Синхронизировано катушек: {{synced}}, пропущено: {{skipped}}",
+    spoolmanAmsSyncError: "Не удалось синхронизировать вес Spoolman из AMS",
+    spoolmanAmsSyncErrorUnreachable: "Не удалось синхронизировать вес Spoolman: сервер недоступен",
+    spoolmanAmsSyncErrorNotConfigured: "Не удалось синхронизировать вес Spoolman: сервер не настроен",
+    spoolmanNotConfigured: "Spoolman не настроен",
+    spoolmanFilamentCatalogTitle: "Каталог филамента Spoolman",
+    spoolmanFilamentCatalogDesc: "Названия филаментов и масса пустых катушек из Spoolman. Здесь можно изменить название и массу катушки; остальные свойства редактируются непосредственно в Spoolman.",
+    spoolmanUrl: "Адрес Spoolman",
+    spoolmanUrlHint: "Адрес сервера Spoolman, например http://localhost:7912",
+    spoolmanConnected: "Подключён",
+    spoolmanDisconnected: "Отключён",
+    status: "Состояние",
+    connect: "Подключить",
+    disconnect: "Отключить",
+    howSyncWorks: "Как работает синхронизация",
+    syncInfoRfidOnly: "Синхронизируются только официальные RFID-катушки Bambu Lab",
+    syncInfoAutoCreate: "Новые катушки автоматически создаются в Spoolman при первой синхронизации",
+    syncInfoThirdPartySkipped: "Неофициальные катушки Bambu Lab, сторонние и перезаправленные катушки пропускаются",
+    linkingExistingSpools: "Привязка существующих катушек",
+    linkingExistingSpoolsDesc: "Чтобы привязать существующую катушку Spoolman к AMS, наведите указатель на слот AMS и нажмите «Привязать к Spoolman».",
+    syncMode: "Режим синхронизации",
+    syncModeAuto: "Автоматически",
+    syncModeManual: "Только вручную",
+    syncModeAutoDesc: "Данные AMS синхронизируются автоматически при обнаружении изменений",
+    syncModeManualDesc: "Синхронизация выполняется только вручную",
+    syncAmsData: "Синхронизировать данные AMS",
+    syncAmsDataDesc: "Вручную синхронизировать данные AMS принтера со Spoolman",
+    allPrinters: "Все принтеры",
+    noDefaultPrinter: "Без принтера по умолчанию (спрашивать каждый раз)",
+    sidebarOrder: "Порядок пунктов боковой панели",
+    saveThumbnails: "Сохранять миниатюры",
+    captureFinishPhoto: "Снимок после завершения",
+    noPrintersConfigured: "Принтеры не настроены",
+    archiveMode: {
+      always: "Всегда создавать запись в архиве",
+      never: "Не создавать запись в архиве",
+      ask: "Спрашивать каждый раз",
+    },
+    checkForUpdatesLabel: "Проверять обновления",
+    checkPrinterFirmware: "Проверять прошивку принтера",
+    includeBetaUpdates: "Включать бета-версии",
+    includeBetaUpdatesDesc: "Уведомлять о бета- и предварительных версиях при проверке обновлений",
+    localLogin: {
+      disable: "Отключить локальный вход по имени пользователя и паролю",
+      disableHint: "Если включено, вход возможен только через провайдеров SSO. На LDAP это не влияет. Чтобы сохранить аварийный способ входа, задайте на сервере BAMBUDDY_LOCAL_LOGIN=true.",
+    },
+    enableRetry: "Включить повторные попытки",
+    homeAssistantDescription: "Управление умными розетками через Home Assistant",
+    environmentManagedLabel: "(управляется окружением)",
+    autoEnabledViaEnv: "Автоматически включено переменными окружения",
+    urlFromEnvReadOnly: "Значение задано переменной окружения HA_URL (только чтение)",
+    tokenFromEnvReadOnly: "Значение задано переменной окружения HA_TOKEN (только чтение)",
+    mqttConnectedTo: "Подключено к",
+    prometheusDescription: "Экспортировать данные принтеров в формате Prometheus",
+    noSmartPlugsTitle: "Умные розетки не настроены",
+    noSmartPlugsDescription: "Добавьте умную розетку на базе Tasmota для учёта энергии и автоматизации питания.",
+    noProvidersTitle: "Провайдеры не настроены",
+    noProvidersDescription: "Добавьте провайдера для получения уведомлений.",
+    noTemplatesAvailable: "Шаблоны отсутствуют. Перезапустите бэкенд, чтобы создать стандартные шаблоны.",
+    apiPermissionView: "Просмотр состояния принтеров и очереди",
+    apiPermissionEdit: "Добавление и удаление заданий из очереди печати",
+    apiKeysEmptyTitle: "Ключей API нет",
+    apiKeysEmptyDescription: "Создайте ключ API для интеграции с внешними сервисами.",
+    noUsersFound: "Пользователи не найдены",
+    noGroupsFound: "Группы не найдены",
+    noGroupsAvailable: "Доступных групп нет",
+    passwordsDoNotMatch: "Пароли не совпадают",
+    systemGroupWarning: "Названия системных групп изменить нельзя",
+    authDisabledTitle: "Аутентификация отключена",
+    authDisabledFeature1: "Требовать вход для доступа к системе",
+    authDisabledFeature2: "Создавать нескольких пользователей с групповыми разрешениями",
+    authDisabledFeature3: "Управлять доступом с помощью более чем 50 отдельных разрешений",
+    userHasCreated: "Этот пользователь создал:",
+    userItemsQuestion: "Что сделать с этими объектами?",
+    deleteUserConfirm: "Удалить этого пользователя?",
+    actionCannotBeUndone: "Это действие нельзя отменить.",
+    addFirstSmartPlug: "Добавить первую умную розетку",
+    providers: "Провайдеры",
+    log: "Журнал",
+    testAll: "Проверить все",
+    testResults: "Результаты проверки",
+    testPassedCount: "Успешно: {{count}}",
+    testFailedCount: "Ошибок: {{count}}",
+    messageTemplates: "Шаблоны сообщений",
+    messageTemplatesDescription: "Настройте тексты уведомлений для каждого события.",
+    apiKeys: "Ключи API",
+    apiKeysDescription: "Создавайте ключи API для внешних интеграций и вебхуков.",
+    createKey: "Создать ключ",
+    apiKeyCreated: "Ключ API успешно создан",
+    apiKeyCopyWarning: "Скопируйте ключ сейчас — повторно он показан не будет!",
+    useInApiBrowser: "Использовать в обозревателе API",
+    apiKeyQrButton: "QR-код",
+    apiKeyQrTitle: "Сканирование для настройки",
+    apiKeyQrCaption: "Отсканируйте мобильным приложением, чтобы добавить этот сервер и ключ API.",
+    apiKeyQrWarning: "QR-код содержит секретный ключ API — не передавайте его и не публикуйте снимок экрана.",
+    createNewApiKey: "Создать ключ API",
+    keyName: "Название ключа",
+    keyNamePlaceholder: "например, Home Assistant или OctoPrint",
+    readStatus: "Чтение состояния",
+    readStatusDescription: "Просмотр состояния принтеров и очереди",
+    manageQueue: "Управление очередью",
+    manageQueueDescription: "Добавление и удаление заданий из очереди печати",
+    controlPrinter: "Управление принтером",
+    controlPrinterDescription: "Пауза, продолжение и остановка печати",
+    manageLibrary: "Управление библиотекой",
+    manageLibraryDescription: "Загрузка, переименование и удаление файлов библиотеки; импорт моделей из MakerWorld",
+    manageInventory: "Управление запасами",
+    manageInventoryDescription: "Создание, изменение и удаление катушек и записей учёта. Требуется для киосков SpoolBuddy: сканирование NFC, показания весов и системные команды киоска.",
+    manageMaintenance: "Управление обслуживанием",
+    manageMaintenanceDescription: "Регистрация выполненного обслуживания, сброс счётчиков, изменение интервалов и управление каталогом видов обслуживания. Подходит для автоматизаций Home Assistant, которые отмечают, например, «сопло очищено», без предоставления более широких прав управления принтером.",
+    manageArchives: "Управление архивом",
+    manageArchivesDescription: "Изменение и удаление записей архива печати, включая старые задания. Не включает удаление их вклада в статистику. Подходит для автоматизаций очистки истории печати.",
+    manageProjects: "Управление проектами",
+    manageProjectsDescription: "Создание, изменение и удаление проектов, а также добавление в них архивных заданий. Подходит для автоматизаций, распределяющих печати по проектам.",
+    libraryBadge: "Библиотека",
+    inventoryBadge: "Запасы",
+    maintenanceBadge: "Обслуживание",
+    archivesBadge: "Архив",
+    projectsBadge: "Проекты",
+    cloudAccess: "Разрешить доступ к облаку",
+    cloudAccessDescription: "Чтение пресетов и филаментов Bambu Cloud от вашего имени. Требуется вход в Bambu Cloud.",
+    cloudBadge: "Облако",
+    updateEnergyCost: "Обновление тарифа на электроэнергию",
+    updateEnergyCostDescription: "Разрешить этому ключу отправлять новую цену за кВт⋅ч методом POST в /settings/electricity-price. Полезно для автоматизаций динамического тарифа Home Assistant (Tibber, Octopus и т. п.). Это единственный параметр настроек, доступный для записи через ключ API.",
+    energyCostBadge: "Энергия",
+    legacyKey: "Устаревший",
+    legacyKeyTooltip: "Создан до появления привязки к пользователям; пересоздайте для доступа к облаку",
+    unnamedKey: "Ключ без названия",
+    lastUsed: "Последнее использование",
+    read: "Чтение",
+    control: "Управление",
+    createFirstKey: "Создать первый ключ",
+    webhookEndpoints: "Конечные точки вебхуков",
+    webhookApiKeyHint: "Передавайте ключ API в заголовке X-API-Key.",
+    webhook: {
+      getAllStatus: "Получить состояние всех принтеров",
+      getSpecificStatus: "Получить состояние выбранного принтера",
+      addToQueue: "Добавить в очередь печати",
+      pausePrint: "Приостановить печать",
+      resumePrint: "Продолжить печать",
+      stopPrint: "Остановить печать",
+    },
+    apiBrowser: "Обозреватель API",
+    apiBrowserDescription: "Просмотр и проверка всех доступных конечных точек API.",
+    apiKeyForTesting: "Ключ API для проверки",
+    apiKeyPlaceholder: "Вставьте ключ API для проверки защищённых конечных точек…",
+    apiKeyHint: "Ключ будет передан в запросе в заголовке X-API-Key.",
+    deleteApiKeyTitle: "Удаление ключа API",
+    deleteApiKeyMessage: "Удалить этот ключ API? Все использующие его интеграции перестанут работать.",
+    deleteKey: "Удалить ключ",
+    amsDisplayThresholds: "Пороговые значения AMS",
+    amsThresholdsDescription: "Настройте цветовые пороги индикаторов влажности и температуры AMS.",
+    humidity: "Влажность",
+    goodGreen: "Норма (зелёный)",
+    fairOrange: "Допустимо (оранжевый)",
+    aboveFairBad: "Выше допустимого порога отображается красным",
+    fairAlsoDryingThreshold: "Этот порог также запускает автоматическую сушку, если она включена",
+    temperature: "Температура",
+    goodBlue: "Норма (синий)",
+    aboveFairHot: "Выше допустимого порога отображается красным как перегрев",
+    historyRetention: "Хранение истории",
+    keepSensorHistory: "Хранить историю датчиков",
+    historyRetentionDescription: "Более старые данные влажности и температуры будут удаляться автоматически",
+    defaultPrintOptions: "Параметры печати по умолчанию",
+    defaultPrintOptionsDescription: "Задайте значения по умолчанию для новых заданий. Их можно изменить отдельно в диалоге запуска печати.",
+    defaultBedLevelling: "Калибровка стола",
+    defaultBedLevellingDesc: "Автоматически выровнять стол перед печатью",
+    defaultFlowCali: "Калибровка потока",
+    defaultFlowCaliDesc: "Калибровать поток экструзии",
+    defaultVibrationCali: "Калибровка вибраций",
+    defaultVibrationCaliDesc: "Уменьшить артефакты звона",
+    defaultLayerInspect: "Проверка первого слоя",
+    defaultLayerInspectDesc: "Проверка первого слоя системой компьютерного зрения",
+    defaultTimelapse: "Таймлапс",
+    defaultTimelapseDesc: "Записывать таймлапс",
+    defaultNozzleOffsetCali: "Калибровка смещения сопел",
+    defaultNozzleOffsetCaliDesc: "Калибровать взаимное смещение сопел экструдеров",
+    tempFanPresetsTitle: "Пресеты температуры и вентиляторов",
+    tempFanPresetsDescription: "Настройте значения быстрого выбора в меню температуры и скорости вентиляторов карточки принтера. Кнопка «Выкл.» отображается всегда.",
+    tempFanPresetsNozzle: "Температура сопла",
+    tempFanPresetsBed: "Температура стола",
+    tempFanPresetsChamber: "Температура камеры",
+    tempFanPresetsFan: "Скорость вентилятора",
+    tempFanPresetsReset: "Восстановить значения по умолчанию",
+    concurrentUploadsTitle: "Одновременные загрузки",
+    concurrentUploadsDescription: "Количество принтеров, на которые очередь может одновременно отправлять файлы. Передача файлов на принтеры выполняется медленно: крупная модель может загружаться несколько минут, а каждый принтер ждёт своей очереди. В большом парке увеличение этого значения сокращает ожидание последних принтеров в группе. Уменьшите его, если сеть или сервер Bambuddy плохо справляются с параллельной передачей.",
+    concurrentUploadsLabel: "Одновременно загружать на принтеры",
+    concurrentUploadsHelp: "Значение 1 отправляет файл только на один принтер за раз, как в прежнем режиме. По умолчанию: 4.",
+    staggeredStart: "Поэтапный запуск",
+    staggeredStartDescription: "Размер группы и интервал по умолчанию при поэтапном запуске партии на нескольких принтерах. Для каждой партии значения можно изменить в окне запуска печати.",
+    preheatTitle: "Преднагрев и выдержка",
+    preheatDescription: "Нагреть стол и, если поддерживается, камеру, затем выдержать температуру перед запуском каждого задания очереди. Полезно для инженерных пластиков (PA, ABS) на принтерах без активного нагревателя камеры: во время выдержки стол прогревает камеру излучением. Целевая температура стола берётся из файла печати; поведение камеры зависит от модели принтера.",
+    preheatEnabled: "Включить преднагрев и выдержку",
+    preheatEnabledDesc: "Если выключено, задания очереди отправляются сразу. Для каждого задания можно задать отдельное значение.",
+    preheatFilamentTargetsLabel: "Целевая температура камеры по типу филамента (°C)",
+    preheatFilamentTargetsHint: "Bambuddy выбирает наибольшее значение среди задействованных слотов AMS; для печати только PLA автоматически используется 0 и этап прогрева камеры пропускается.",
+    preheatFilamentTargetsReset: "Восстановить значения по умолчанию",
+    preheatFilamentTargetsDefaultRow: "Прочее / без сопоставления",
+    preheatMaxWait: "Максимальное ожидание (с)",
+    preheatMaxWaitHelp: "Предельное время прогрева камеры, после которого процесс продолжится.",
+    preheatSoak: "Выдержка (с)",
+    preheatSoakHelp: "Время выдержки после достижения цели или истечения максимального ожидания.",
+    preheatHardwareTitle: "Поведение по моделям принтеров:",
+    preheatHardwareDetail: "H2C/H2D/H2D Pro/H2S/X2D/X1E активно нагревают камеру командой M141. X1C/P2S измеряют температуру камеры, но прогревают её только теплом стола. У P1S/P1P/A1/A1 Mini нет датчика камеры — применяется только таймер выдержки.",
+    preheatPerItemDesc: "Нагревать стол и камеру перед запуском этого задания. По умолчанию используется общий переключатель «Настройки → Рабочий процесс».",
+    preheatOverride_inherit: "Наследовать",
+    preheatOverride_on: "Включено",
+    preheatOverride_off: "Выключено",
+    preheatTargetOverride: "Переопределение температуры камеры (°C; пусто — значение для филамента)",
+    plateClear: "Подтверждение очистки пластины",
+    requirePlateClear: "Требовать подтверждение очистки пластины",
+    requirePlateClearDescription: "Если включено, планировщик не запускает следующее задание на принтере с завершённой печатью, пока не подтверждена очистка пластины. Отключение также скрывает индикатор состояния пластины и кнопку «Пластина очищена» на карточках принтеров.",
+    gcodeInjection: "Вставка G-кода",
+    gcodeInjectionDescription: "Настройте G-код, добавляемый в начало и/или конец печати для систем автоматического запуска, таких как Farmloop, SwapMod, AutoClear и Printflow 3D. Фрагменты задаются отдельно для каждой модели принтера и применяются, когда для задания очереди включена вставка G-кода.",
+    gcodeInjectionNoPrinters: "Принтеры не найдены. Добавьте принтеры, чтобы настроить фрагменты G-кода.",
+    gcodeStartLabel: "Начальный G-код",
+    gcodeEndLabel: "Завершающий G-код",
+    gcodeStartPlaceholder: "G-код, добавляемый перед началом печати…",
+    gcodeEndPlaceholder: "G-код, добавляемый после завершения печати…",
+    staggerGroupSize: "Размер группы",
+    staggerGroupSizeHelp: "Количество принтеров, запускаемых одновременно",
+    staggerInterval: "Интервал (мин)",
+    staggerIntervalHelp: "Задержка между запуском групп",
+    queueDrying: "Автоматическая сушка в очереди",
+    queueDryingDescription: "Автоматически сушить филамент в AMS, когда принтер простаивает между заданиями очереди. Запуск определяется указанным выше порогом влажности.",
+    queueDryingEnabled: "Включить автоматическую сушку",
+    queueDryingEnabledDescription: "Автоматически запускать сушку AMS, когда принтер простаивает и влажность выше порога",
+    queueDryingBlock: "Ожидать завершения сушки",
+    queueDryingBlockDescription: "Блокировать очередь печати до завершения сушки. Если выключено, печать имеет приоритет.",
+    ambientDryingEnabled: "Фоновая сушка",
+    ambientDryingEnabledDescription: "Автоматически сушить филамент на простаивающих принтерах при превышении порога влажности, даже если очередь пуста.",
+    printDryingEnabled: "Продолжать сушку во время печати",
+    printDryingEnabledDescription: "Разрешить автоматической сушке продолжаться во время печати на поддерживаемом оборудовании (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L с актуальной прошивкой). Для защиты катушек температура автоматически ограничивается на 5 °C ниже пресета сушки в простое.",
+    dryingPresets: "Пресеты сушки",
+    dryingPresetsDescription: "Температура и длительность для каждого типа филамента. AMS 2 Pro использует более низкие температуры, AMS-HT поддерживает более высокие.",
+    dryingFilament: "Филамент",
+    humidityThresholds: "Пороги влажности",
+    humidityThresholdsDescription: "Порог запуска автоматической сушки и предупреждений для каждого типа филамента. При смешанной загрузке AMS используется наименьший порог.",
+    humidityThresholdCol: "Порог",
+    humidityThresholdDefault: "По умолчанию (неизвестные типы)",
+    printModal: "Окно запуска печати",
+    expandCustomMapping: "Разворачивать пользовательское сопоставление",
+    expandCustomMappingDescription: "При печати на нескольких принтерах сразу показывать развёрнутое сопоставление AMS для каждого принтера",
+    authentication: "Аутентификация",
+    authEnabledDescription: "Доступ к экземпляру защищён аутентификацией пользователей",
+    authDisabledDescription: "Включите, чтобы требовать вход и управлять доступом пользователей",
+    authDisabledMessage: "Включите аутентификацию, чтобы создавать учётные записи, управлять правами и защитить экземпляр Bambuddy.",
+    enableAuthentication: "Включить аутентификацию",
+    currentUser: "Текущий пользователь",
+    changePassword: "Изменить пароль",
+    admin: "Администратор",
+    users: "Пользователи",
+    addUser: "Добавить пользователя",
+    groups: "Группы",
+    addGroup: "Добавить группу",
+    system: "Система",
+    noDescription: "Нет описания",
+    userCount: "Пользователей: {{count}}",
+    permissionCount: "Разрешений: {{count}}",
+    createUser: "Создать пользователя",
+    username: "Имя пользователя",
+    enterUsername: "Введите имя пользователя",
+    password: "Пароль",
+    enterPassword: "Введите пароль",
+    passwordRequirements: "Не менее 8 символов: одна заглавная и одна строчная буква, одна цифра и один специальный символ.",
+    confirmPassword: "Подтвердите пароль",
+    confirmPasswordPlaceholder: "Повторите пароль",
+    viewReleaseOnGitHub: "Открыть выпуск на GitHub",
+    turnAllPlugsOn: "Включить все розетки",
+    turnAllPlugsOff: "Выключить все розетки",
+    clearNotificationLogs: "Очистить журнал уведомлений",
+    clearLogsMessage: "Все записи журнала уведомлений старше 30 дней будут безвозвратно удалены. Это действие нельзя отменить.",
+    clearLogs: "Очистить журнал",
+    resetUiPreferences: "Сбросить настройки интерфейса",
+    resetUiPreferencesMessage: "Будут сброшены порядок боковой панели, тема, компоновка панели, режимы отображения и сортировка. Принтеры, архивы и серверные настройки НЕ изменятся. После очистки страница перезагрузится.",
+    resetPreferences: "Сбросить настройки",
+    deleteGroupTitle: "Удаление группы",
+    deleteGroupMessage: "Удалить эту группу? Пользователи группы потеряют назначенные ей разрешения.",
+    deleteGroup: "Удалить группу",
+    disableAuthenticationTitle: "Отключение аутентификации",
+    disableAuthenticationMessage: "Отключить аутентификацию? Bambuddy станет доступен без входа. Все пользователи останутся в базе данных, но проверка учётных данных выполняться не будет.",
+    disableAuthentication: "Отключить аутентификацию",
+    configureBambuddy: "Настроить Bambuddy",
+    systemDefault: "Системное значение",
+    archiveSettings: "Настройки архива",
+    newWindow: "Новое окно",
+    embeddedOverlay: "Встроенное наложение",
+    preferredSlicer: "Предпочитаемый слайсер",
+    preferredSlicerDescription: "Слайсер для нарезки внутри приложения через API-службу",
+    openInSlicerLabel: "Открывать в слайсере",
+    openInSlicerInherit: "Как для API-слайсера",
+    openInSlicerDescription: "Настольный слайсер, который открывается кнопкой «Открыть в слайсере». Оставьте «Как для API-слайсера», чтобы наследовать настройку, либо выберите другой локальный слайсер.",
+    orcaslicerKnownIssuesWarning: "В OrcaSlicer 2.3.2 и 2.4.0-dev известны ошибки CLI, мешающие нарезке многих 3MF от Bambu: #12426 (сбой на раскрашенных файлах с несколькими экструдерами) и #13386 (слишком строгая проверка диапазонов параметров). До исправления рекомендуется Bambu Studio.",
+    useSlicerApi: "Использовать API слайсера",
+    useSlicerApiDescription: "Если включено, действия «Нарезать» открывают встроенное окно и обращаются к API-службе слайсера. Если выключено, файл передаётся настольному слайсеру через URI-схему.",
+    slicerCard: "Слайсер",
+    orcaslicerApiUrl: "URL API-службы OrcaSlicer",
+    bambuStudioApiUrl: "URL API-службы Bambu Studio",
+    slicerApiUrlDescription: "URL контейнера API-службы слайсера. Оставьте пустым, чтобы использовать значения переменных окружения SLICER_API_URL или BAMBU_STUDIO_API_URL.",
+    slicerBundlesRemoved: {
+      title: "Пакеты профилей слайсера (удалено)",
+      description: "Импорт пакетов профилей принтера .bbscfg удалён. Экспорт пакета из BambuStudio содержит только пользовательские профили, поэтому стандартные процессы и филаменты не импортировались, а нарезка использовала встроенные настройки.",
+      alternatives: "Для отдельных пользовательских профилей используйте импорт одного профиля либо синхронизацию через Bambu Cloud или Orca Cloud. Стандартные профили автоматически предоставляет служба слайсера.",
+      lookupOrder: "Порядок поиска профиля при нарезке: 1) импортированный локально, 2) Orca Cloud, 3) Bambu Cloud, 4) стандартный профиль службы слайсера.",
+    },
+    externalCameras: "Внешние камеры",
+    costTracking: "Учёт затрат",
+    printsOnly: "Только печать",
+    totalConsumption: "Общее потребление",
+    dataManagement: "Управление данными",
+    storageUsage: "Использование хранилища",
+    storageUsageDescription: "Распределение занятого места по категориям",
+    storageUsageTotal: "Всего",
+    storageUsageErrors: "Ошибки",
+    storageUsageOtherBreakdown: "Прочее (статические ресурсы, скрипты и конфигурационные файлы)",
+    storageUsageSystem: "Система",
+    storageUsageData: "Данные",
+    storageUsageUnavailable: "Сведения об использовании хранилища недоступны",
+    clearNotificationLogsDescription: "Удалить записи журнала уведомлений старше 30 дней",
+    resetUiPreferencesDescription: "Сбросить порядок боковой панели, тему, режимы отображения и компоновку. Принтеры, архивы и настройки не изменятся.",
+    enableHomeAssistant: "Включить Home Assistant",
+    enableMqtt: "Включить MQTT",
+    useTls: "Использовать TLS",
+    enableMetricsEndpoint: "Включить endpoint метрик",
+    availableMetrics: "Доступные метрики",
+    editUser: "Изменить пользователя",
+    deleteUserTitle: "Удаление пользователя",
+    groupName: "Название группы",
+    leaveEmptyForAnonymous: "Оставьте пустым для анонимного доступа",
+    leaveEmptyForNoAuth: "Оставьте пустым, чтобы не использовать аутентификацию",
+    enterNewPassword: "Введите новый пароль",
+    confirmNewPassword: "Повторите новый пароль",
+    enterGroupName: "Введите название группы",
+    enterDescriptionOptional: "Введите описание (необязательно)",
+    enterCurrentPassword: "Введите текущий пароль",
+    enterNewPasswordMin6: "Введите новый пароль (не менее 6 символов)",
+    toast: {
+      keyCopied: "Ключ скопирован в буфер обмена",
+      copyFailed: "Не удалось скопировать ключ",
+      keyAddedToBrowser: "Ключ добавлен в обозреватель API",
+      clearLogsFailed: "Не удалось очистить журнал",
+      uiPreferencesReset: "Настройки интерфейса сброшены. Обновление страницы…",
+      authDisabled: "Аутентификация отключена",
+      authDisableFailed: "Не удалось отключить аутентификацию",
+      apiKeyCreated: "Ключ API создан",
+      apiKeyDeleted: "Ключ API удалён",
+      userCreated: "Пользователь создан",
+      userUpdated: "Пользователь обновлён",
+      userDeleted: "Пользователь удалён",
+      groupCreated: "Группа создана",
+      groupUpdated: "Группа обновлена",
+      groupDeleted: "Группа удалена",
+      fillRequiredFields: "Заполните все обязательные поля",
+      passwordsDoNotMatch: "Пароли не совпадают",
+      passwordTooShort: "Пароль должен содержать не менее 8 символов",
+      passwordNeedsUppercase: "Пароль должен содержать хотя бы одну заглавную букву",
+      passwordNeedsLowercase: "Пароль должен содержать хотя бы одну строчную букву",
+      passwordNeedsDigit: "Пароль должен содержать хотя бы одну цифру",
+      passwordNeedsSpecial: "Пароль должен содержать хотя бы один специальный символ",
+      enterGroupName: "Введите название группы",
+      settingsSaved: "Настройки сохранены",
+      noPermissionUpdate: "У вас нет прав на изменение настроек",
+      cameraSettingsSaved: "Настройки камеры сохранены",
+      enterCameraUrl: "Введите URL камеры",
+      passwordChanged: "Пароль изменён",
+      connectionFailed: "Не удалось подключиться",
+      testFailed: "Проверка завершилась ошибкой",
+      cameraConnected: "Камера подключена{{resolution}}",
+    },
+    testConnection: "Проверить подключение",
+    catalog: {
+      spoolCatalog: "Каталог катушек",
+      spoolCatalogDescription: "Масса пустых катушек по производителю и типу. Используется для автоматического определения массы при добавлении катушек.",
+      searchCatalog: "Поиск по каталогу…",
+      addNewEntry: "Добавить запись",
+      namePlaceholder: "Название (например, Bambu Lab — пластик)",
+      weight: "Масса",
+      type: "Тип",
+      default: "Стандартная",
+      custom: "Пользовательская",
+      noMatch: "Ничего не найдено",
+      empty: "Каталог пуст",
+      deleteEntry: "Удалить запись",
+      deleteConfirm: "Удалить «{{name}}»?",
+      resetCatalog: "Сбросить каталог",
+      resetConfirm: "Сбросить каталог к исходному состоянию? Все пользовательские записи будут удалены.",
+      loadFailed: "Не удалось загрузить каталог катушек",
+      nameWeightRequired: "Укажите название и массу",
+      entryAdded: "Запись добавлена",
+      addFailed: "Не удалось добавить запись",
+      entryUpdated: "Запись обновлена",
+      updateFailed: "Не удалось обновить запись",
+      entryDeleted: "Запись удалена",
+      deleteFailed: "Не удалось удалить запись",
+      resetSuccess: "Каталог сброшен к исходному состоянию",
+      resetFailed: "Не удалось сбросить каталог",
+      exported: "Экспортировано записей: {{count}}",
+      imported: "Импортировано записей: {{added}}, пропущено: {{skipped}}",
+      importFailed: "Не удалось импортировать: неверный формат JSON",
+      exportTooltip: "Экспортировать каталог в JSON",
+      importTooltip: "Импортировать каталог из JSON",
+      resetTooltip: "Сбросить к исходному состоянию",
+      selectedCount: "Выбрано: {{count}}",
+      deleteSelected: "Удалить выбранные",
+      bulkDeleteConfirm: "Удалить выбранные записи ({{count}})?",
+      bulkDeleted: "Удалено записей: {{count}}",
+      bulkDeleteFailed: "Не удалось удалить записи",
+      material: "Материал",
+      spoolWeight: "Масса катушки",
+      color: "Цвет",
+      updateSpoolWeight: "Обновить массу катушки",
+      filamentUpdated: "Филамент обновлён",
+      filamentUpdateFailed: "Не удалось обновить филамент",
+      filamentUpdateInvalid: "Некорректные данные филамента",
+      keepExistingSpoolWeight: "Сохранить прежнюю массу у существующих катушек",
+      keepExistingSpoolWeightDesc: "Катушки, уже созданные с этим типом филамента, сохранят прежнюю массу тары. Для новых катушек будет использоваться обновлённое значение.",
+      applyToAllSpools: "Применить ко всем катушкам",
+      applyToAllSpoolsDesc: "Все расчёты массы для катушек этого типа немедленно начнут использовать новую массу тары.",
+    },
+    colorCatalog: {
+      title: "Каталог цветов",
+      description: "Цвета филамента по производителю и материалу. Используются для автоматического определения цвета при добавлении катушек.",
+      searchColors: "Поиск цветов…",
+      allManufacturers: "Все производители",
+      addNewColor: "Добавить цвет",
+      manufacturer: "Производитель",
+      colorName: "Название цвета",
+      hex: "HEX",
+      materialOptional: "Материал (необязательно)",
+      showing: "Показано {{filtered}} из {{total}} цветов",
+      noMatch: "Подходящие цвета не найдены",
+      empty: "Каталог цветов пуст",
+      deleteColor: "Удалить цвет",
+      deleteConfirm: "Удалить «{{name}}»?",
+      resetCatalog: "Сбросить каталог цветов",
+      resetConfirm: "Сбросить каталог к исходному состоянию? Все пользовательские цвета будут удалены.",
+      sync: "Синхронизировать",
+      starting: "Запуск…",
+      syncTooltip: "Синхронизировать с FilamentColors.xyz (более 2000 цветов; может занять около минуты)",
+      loadFailed: "Не удалось загрузить каталог цветов",
+      fieldsRequired: "Укажите производителя, название цвета и HEX-код",
+      colorAdded: "Цвет добавлен",
+      addFailed: "Не удалось добавить цвет",
+      colorUpdated: "Цвет обновлён",
+      updateFailed: "Не удалось обновить цвет",
+      colorDeleted: "Цвет удалён",
+      deleteFailed: "Не удалось удалить цвет",
+      resetSuccess: "Каталог цветов сброшен к исходному состоянию",
+      resetFailed: "Не удалось сбросить каталог цветов",
+      syncUpToDate: "Каталог уже актуален (проверено цветов: {{count}})",
+      syncComplete: "Добавлено новых цветов: {{added}}, уже существовало: {{skipped}}",
+      syncError: "Ошибка синхронизации",
+      syncFailed: "Не удалось синхронизировать каталог с FilamentColors.xyz",
+      exported: "Экспортировано цветов: {{count}}",
+      imported: "Импортировано цветов: {{added}}, пропущено: {{skipped}}",
+      importFailed: "Не удалось импортировать: неверный формат JSON",
+      selectedCount: "Выбрано: {{count}}",
+      deleteSelected: "Удалить выбранные",
+      bulkDeleteConfirm: "Удалить выбранные цвета ({{count}})?",
+      bulkDeleted: "Удалено цветов: {{count}}",
+      bulkDeleteFailed: "Не удалось удалить цвета",
+    },
+    dateFormat: "Формат даты",
+    dateFormatUs: "США (ММ/ДД/ГГГГ)",
+    dateFormatEu: "Европа (ДД/ММ/ГГГГ)",
+    dateFormatIso: "ISO (ГГГГ-ММ-ДД)",
+    timeFormat: "Формат времени",
+    timeFormat12: "12-часовой (3:30 PM)",
+    timeFormat24: "24-часовой (15:30)",
+    defaultPrinter: "Принтер по умолчанию",
+    defaultPrinterDescription: "Предварительно выбирать этот принтер при загрузке файлов, повторной печати и других операциях.",
+    slicerBambuStudio: "Bambu Studio",
+    slicerOrcaSlicer: "OrcaSlicer",
+    sidebarOrderDescription: "Изменяйте порядок, видимость и пользовательские ссылки в настройках боковой панели.",
+    setDefault: "Сделать стандартным",
+    sidebarOrderSetDefaultHint: "Текущий порядок меню будет применяться к пользователям, которые ещё не настроили его самостоятельно.",
+    sidebarDefaultSet: "Стандартный порядок меню сохранён.",
+    sidebarDefaultCleared: "Стандартный порядок меню сброшен.",
+    sidebarDefaultFailed: "Не удалось сохранить стандартный порядок меню.",
+    reset: "Сбросить",
+    darkMode: "Тёмная тема",
+    lightMode: "Светлая тема",
+    active: "(активна)",
+    background: "Фон",
+    accent: "Акцент",
+    style: "Стиль",
+    bgNeutral: "Нейтральный",
+    bgWarm: "Тёплый",
+    bgCool: "Холодный",
+    bgOled: "Чёрный OLED",
+    bgSlate: "Сланцево-синий",
+    bgForest: "Лесной зелёный",
+    accentGreen: "Зелёный",
+    accentTeal: "Бирюзовый",
+    accentBlue: "Синий",
+    accentOrange: "Оранжевый",
+    accentPurple: "Фиолетовый",
+    accentRed: "Красный",
+    styleClassic: "Классический",
+    styleGlow: "Свечение",
+    styleVibrant: "Насыщенный",
+    themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.",
+    autoArchivePrints: "Автоматически архивировать печать",
+    autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
+    saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
+    captureFinishPhotoDescription: "Сделать снимок камерой принтера после завершения печати. Во время печати Bambuddy записывает короткий таймлапс, чтобы получить кадр до опускания стола. Если таймлапс был включён для задания, файл сохранится; иначе после получения снимка он будет автоматически удалён.",
+    ffmpegNotInstalled: "ffmpeg не установлен",
+    ffmpegRequired: "Для захвата изображения требуется ffmpeg. Установите его командой <brew>brew install ffmpeg</brew> в macOS или <apt>apt install ffmpeg</apt> в Linux.",
+    camera: "Камера",
+    cameraViewMode: "Режим просмотра камеры",
+    cameraOverlayDescription: "Камера открывается поверх основного экрана в окне с изменяемым размером",
+    cameraWindowDescription: "Камера открывается в отдельном окне браузера",
+    externalCamerasDescription: "Настройка внешних камер вместо встроенной камеры принтера. Поддерживаются потоки MJPEG, RTSP, HTTP-снимки и USB-камеры V4L2. Внешняя камера используется для просмотра в реальном времени и снимков после завершения печати.",
+    cameraPlaceholderUsb: "Путь к устройству (/dev/video0)",
+    cameraPlaceholderUrl: "URL камеры (rtsp://… или http://…)",
+    cameraTypeMjpeg: "Поток MJPEG",
+    cameraTypeRtsp: "Поток RTSP",
+    cameraTypeSnapshot: "HTTP-снимок",
+    cameraTypeUsb: "USB-камера (V4L2)",
+    cameraSnapshotUrl: "URL снимка (необязательно)",
+    cameraSnapshotUrlPlaceholder: "http://192.168.1.61:1984/api/frame.jpeg?src=printer",
+    cameraSnapshotUrlHelp: "URL одиночного кадра для миниатюр уведомлений, снимков после печати, кадров послойного таймлапса и распознавания пластины. Для таймлапса и распознавания пластины должны быть отдельно включены соответствующие настройки принтера; этот URL задаёт лишь источник изображения. Оставьте пустым, чтобы получать кадры из указанного выше видеопотока. Полезно для go2rtc (/api/frame.jpeg) и IP-камер с отдельным endpoint снимка.",
+    cameraRotation: "Поворот",
+    test: "Проверить",
+    connected: "Подключено",
+    disconnected: "Отключено",
+    currency: "Валюта",
+    defaultFilamentCost: "Стоимость филамента по умолчанию (за кг)",
+    electricityCost: "Стоимость электроэнергии за кВт·ч",
+    energyDisplayMode: "Режим отображения энергии",
+    energyModePrintDescription: "На панели показывается сумма энергии, израсходованной во время печати",
+    energyModeTotalDescription: "На панели показывается полный расход энергии по данным умных розеток",
+    fileManager: "Файловый менеджер",
+    createArchiveEntry: "Создавать запись в архиве при печати",
+    createArchiveEntryDescription: "При запуске печати из файлового менеджера при необходимости создавать запись в архиве",
+    lowDiskSpaceWarning: "Предупреждение о нехватке места",
+    lowDiskSpaceDescription: "Показывать предупреждение, когда свободного места остаётся меньше указанного порога",
+    printerFirmware: "Прошивка принтера",
+    checkFirmwareDescription: "Проверять наличие обновлений прошивки принтера у Bambu Lab",
+    bambuddySoftware: "Программное обеспечение Bambuddy",
+    autoCheckDescription: "Автоматически проверять наличие новых версий при запуске",
+    checkNow: "Проверить сейчас",
+    updateAvailableVersion: "Доступно обновление: v{{version}}",
+    releaseNotes: "Примечания к выпуску",
+    updateViaDocker: "Обновление через Docker Compose:",
+    updateViaHomeAssistant: "Обновления устанавливаются через Home Assistant Supervisor. Чтобы установить новую версию, откройте в Home Assistant: Настройки → Дополнения → Bambuddy.",
+    updateViaWindowsInstaller: "В Windows обновление выполняется повторным запуском установщика. Скачайте новую версию ниже — данные, настройки и принтеры сохранятся.",
+    downloadWindowsInstaller: "Скачать установщик v{{version}}",
+    installUpdate: "Установить обновление",
+    latestVersionRunning: "Установлена последняя версия",
+    failedToCheckUpdates: "Не удалось проверить обновления: {{error}}",
+    backupRestore: "Резервное копирование и восстановление",
+    backupRestoreDescription: "Экспорт и импорт настроек, а также резервное копирование в GitHub",
+    goToBackup: "Перейти к резервным копиям",
+    externalUrl: "Внешний URL",
+    externalUrlDescription: "Внешний адрес, по которому доступен Bambuddy. Используется для изображений в уведомлениях и внешних интеграций.",
+    bambuddyUrl: "URL Bambuddy",
+    externalUrlHint: "Укажите протокол и порт, например http://192.168.1.100:8000",
+    ftpRetry: "Повтор FTP-операций",
+    ftpRetryDescription: "Повторять FTP-операции при нестабильном Wi‑Fi принтера. Применяется к скачиванию 3MF и таймлапсов, загрузке заданий и обновлению прошивки.",
+    autoRetryDescription: "Автоматически повторять неудачные FTP-операции",
+    retryAttempts: "Количество попыток",
+    retryDelay: "Задержка между попытками",
+    connectionTimeout: "Тайм-аут подключения",
+    time_one: "{{count}} раз",
+    time_other: "{{count}} раз",
+    second_one: "{{count}} секунда",
+    second_other: "{{count}} секунд",
+    nSeconds: "{{count}} секунд",
+    increaseForWeakWifi: "Увеличьте для принтеров со слабым Wi‑Fi",
+    homeAssistant: "Home Assistant",
+    homeAssistantFullDescription: "Подключение к Home Assistant для управления умными розетками через REST API. Поддерживаются сущности switch, light, input_boolean и script.",
+    homeAssistantUrl: "URL Home Assistant",
+    longLivedAccessToken: "Долгосрочный токен доступа",
+    haTokenHint: "Создайте токен в Home Assistant: Профиль → Долгосрочные токены доступа → Создать токен",
+    connectionSuccessful: "Подключение установлено",
+    connectionFailed: "Подключиться не удалось",
+    haConnectionSuccess: "Подключение к Home Assistant установлено.",
+    haConnectionFailed: "Не удалось подключиться к Home Assistant.",
+    mqttPublishing: "Публикация MQTT",
+    mqttDescription: "Публикация событий Bambuddy во внешнем MQTT-брокере для интеграции с Node-RED, Home Assistant и другими системами автоматизации.",
+    mqttEnableDescription: "Публиковать события во внешнем MQTT-брокере",
+    brokerHostname: "Имя хоста брокера",
+    port: "Порт",
+    usernameOptional: "Имя пользователя (необязательно)",
+    passwordOptional: "Пароль (необязательно)",
+    topicPrefix: "Префикс топиков",
+    topicPrefixHint: "Топики будут иметь вид {{prefix}}/printers/<serial>/status и т. п.",
+    prometheusMetrics: "Метрики Prometheus",
+    prometheusEndpointDescription: "Публиковать метрики принтеров по адресу <code>/api/v1/metrics</code> для мониторинга в Prometheus/Grafana.",
+    bearerTokenOptional: "Bearer-токен (необязательно)",
+    bearerTokenHint: "Если задан, запросы должны содержать <code>Authorization: Bearer <token></code>",
+    metricsConnectionStatus: "Состояние подключения",
+    metricsPrinterState: "Состояние принтера (ожидание, печать и т. п.)",
+    metricsPrintProgress: "Прогресс печати, 0–100 %",
+    metricsBedTemp: "Температура стола",
+    metricsNozzleTemp: "Температура сопла",
+    metricsPrintsTotal: "Общее количество заданий по результату",
+    metricsMore: "…и другие данные: слои, вентиляторы, очередь, расход филамента",
+    smartPlugsDescription: "Подключение умных розеток Tasmota или Home Assistant для автоматического управления питанием принтеров и учёта энергопотребления.",
+    allOn: "Включить все",
+    allOff: "Выключить все",
+    addSmartPlug: "Добавить умную розетку",
+    energySummary: "Сводка энергопотребления",
+    currentPower: "Текущая мощность",
+    plugsOnline: "Розеток в сети: {{reachable}}/{{total}}",
+    today: "Сегодня",
+    yesterday: "Вчера",
+    total: "Всего",
+    enablePlugsForSummary: "Включите розетки, чтобы увидеть сводку энергопотребления",
+    addNotificationProvider: "Добавить",
+    systemBadge: "(системная)",
+    creating: "Создание…",
+    changing: "Изменение…",
+    deleteUserAndItems: "Удалить пользователя И его объекты",
+    deleteUserKeepItems: "Удалить пользователя, объекты оставить без владельца",
+    ok: "ОК",
+    twoFa: {
+      totpTitle: "Приложение-аутентификатор (TOTP)",
+      totpDesc: "Использовать приложение-аутентификатор, например Google Authenticator, Aegis или Authy.",
+      emailOtpTitle: "Одноразовый код по электронной почте",
+      emailOtpDesc: "Отправлять одноразовый код на {{email}} при входе.",
+      emailOtpNoEmail: "Добавьте адрес электронной почты в учётную запись, чтобы включить этот способ.",
+      addEmailFirst: "В вашей учётной записи нет адреса электронной почты. Перед включением одноразовых кодов попросите администратора добавить его.",
+      setupTotp: "Настроить приложение-аутентификатор",
+      setupAuthApp: "Настроить приложение-аутентификатор",
+      setupInstructions: "Отсканируйте QR-код приложением-аутентификатором, затем подтвердите настройку кодом.",
+      manualEntry: "Не получается отсканировать? Введите этот секрет вручную:",
+      scannedContinue: "Код отсканирован — продолжить",
+      enterCodeToConfirm: "Для подтверждения настройки введите шестизначный код из приложения-аутентификатора.",
+      activate: "Активировать",
+      disableTotp: "Отключить аутентификатор",
+      disableConfirmHint: "Чтобы отключить аутентификатор, введите действующий код TOTP или резервный код.",
+      totpDisabled: "Приложение-аутентификатор отключено.",
+      emailOtpEnabled: "Одноразовые коды по электронной почте включены.",
+      emailOtpDisabled: "Одноразовые коды по электронной почте отключены.",
+      smtpRequired: "Сначала настройте и проверьте SMTP.",
+      invalidCode: "Неверный код. Повторите попытку.",
+      enableEmailOtp: "Включить одноразовые коды по электронной почте",
+      disableEmailOtp: "Отключить одноразовые коды по электронной почте",
+      emailSetupEnterCode: "Код подтверждения отправлен на вашу электронную почту. Введите его ниже, чтобы подтвердить доступ к этому почтовому ящику.",
+      verifyAndEnable: "Подтвердить и включить",
+      emailDisablePasswordHint: "Чтобы подтвердить отключение одноразовых кодов по электронной почте, введите пароль учётной записи.",
+      passwordPlaceholder: "Введите пароль",
+      backupCodesTitle: "Сохраните резервные коды",
+      backupCodesWarning: "Сохраните эти коды в надёжном месте. Каждый код можно использовать только один раз; повторно они показаны не будут.",
+      backupCodesRemaining: "Осталось резервных кодов: {{count}}",
+      savedCodes: "Я сохранил коды",
+      regenBackup: "Создать новые резервные коды",
+      regenBackupHint: "Введите текущий код TOTP, чтобы создать 10 новых резервных кодов. Все существующие резервные коды станут недействительными.",
+      newBackupCodes: "Новые резервные коды",
+      linkedAccounts: "Связанные учётные записи SSO",
+      linkedAccountsDesc: "Эти внешние провайдеры идентификации связаны с вашей учётной записью.",
+      oidcUnlinked: "Учётная запись отсоединена.",
+    },
+    sessionPolicy: {
+      title: "Политика сеансов",
+      description: "Максимальный срок действия сеанса для новых входов пользователей. Уже выданные токены сохраняют исходный срок действия.",
+      preset24h: "24 часа",
+      preset7d: "7 дней",
+      preset30d: "30 дней",
+      customHoursLabel: "Срок действия сеанса в часах",
+      hoursSuffix: "часов",
+      warning: "Длительные сеансы снижают защиту автоматическим выходом. Рекомендуется только для доверенных установок с одним пользователем.",
+    },
+    oidc: {
+      title: "Провайдеры SSO / OIDC",
+      desc: "Настройка провайдеров OpenID Connect для единого входа через внешние службы идентификации.",
+      addProvider: "Добавить провайдера",
+      newProvider: "Новый провайдер",
+      empty: "Провайдеры OIDC пока не настроены.",
+      created: "Провайдер создан.",
+      updated: "Провайдер обновлён.",
+      deleted: "Провайдер удалён.",
+      refreshIcon: "Обновить значок",
+      removeIcon: "Удалить значок",
+      iconRefreshed: "Значок обновлён.",
+      iconRemoved: "Значок удалён.",
+      iconFetchFailed: "Не удалось получить значок по URL провайдера.",
+      deleteTitle: "Удалить провайдера",
+      deleteMessage: "Удалить «{{name}}»? Все связанные учётные записи пользователей будут отсоединены.",
+      form: {
+        name: "Отображаемое имя",
+        issuerUrl: "URL издателя",
+        clientId: "Идентификатор клиента",
+        clientSecret: "Секрет клиента",
+        scopes: "Области доступа",
+        iconUrl: "URL значка (необязательно)",
+        enabled: "Включён",
+        autoCreate: "Автоматически создавать пользователей",
+        autoCreateDesc: "Автоматически создавать локальную учётную запись при первом входе.",
+        autoLink: "Автоматически связывать существующие учётные записи",
+        autoLinkDesc: "При первом входе связывать локальные учётные записи по совпадающему адресу электронной почты.",
+        secretHint: "оставьте пустым, чтобы сохранить текущий",
+        secretPlaceholder: "новый секрет",
+        emailClaim: "Поле электронной почты",
+        emailClaimDesc: "Поле JWT, используемое как адрес электронной почты. Для Azure Entra ID, который не передаёт email_verified, используйте preferred_username или upn. Указывайте только доверенные поля.",
+        emailClaimPlaceholder: "email",
+        emailClaimCustomClaimAutoLinkWarning: "Пользовательское поле безопасно для автоматического связывания только тогда, когда его значение назначается администратором арендатора, например upn или preferred_username в Azure Entra ID. Не включайте автоматическое связывание, если ваш IdP позволяет пользователям самостоятельно задавать это поле.",
+        requireEmailVerified: "Требовать подтверждённый адрес электронной почты",
+        requireEmailVerifiedDesc: "Принимать адрес электронной почты только в том случае, если провайдер пометил его как подтверждённый.",
+        requireEmailVerifiedWarning: "Предупреждение: адрес будет принят даже без подтверждения. Используйте только с доверенными провайдерами.",
+        requireEmailVerifiedAutoLink: "Чтобы изменить этот параметр, сначала отключите автоматическое связывание.",
+        defaultGroup: "Группа по умолчанию",
+        defaultGroupDesc: "Группа для автоматически созданных пользователей. Если не задана, используется группа «Наблюдатели».",
+        defaultGroupViewersFallback: "Наблюдатели (по умолчанию)",
+        autologin: "Автоматический вход",
+        autologinDesc: "Сразу перенаправлять неавторизованных посетителей к этому провайдеру. Этот флаг может быть установлен только у одного провайдера.",
+      },
+    },
+    encryption: {
+      title: "Состояние шифрования MFA",
+      enabledFromEnv: "Шифрование данных включено; ключ получен из переменной окружения MFA_ENCRYPTION_KEY",
+      enabledFromFile: "Шифрование данных включено; ключ загружен из каталога данных",
+      enabledGenerated: "Шифрование данных включено с автоматически созданным ключом",
+      notConfigured: "Шифрование хранимых данных не настроено",
+      notConfiguredDesc: "Секреты TOTP и client_secret провайдеров OIDC хранятся открытым текстом. Задайте MFA_ENCRYPTION_KEY или перезапустите Bambuddy с доступным для записи каталогом данных, чтобы ключ был создан автоматически.",
+      allEncrypted: "Все секреты MFA зашифрованы при хранении.",
+      legacyRowsLabel: "Старые записи открытым текстом",
+      encryptedRowsLabel: "Зашифрованные записи",
+      legacyRowsWarning: "Обнаружено старых записей открытым текстом: {{count}}. Чтобы перенести их в зашифрованное хранилище, повторно сохраните провайдера OIDC или заново подключите приложение-аутентификатор пользователя.",
+      backupHint: "Автоматически созданный ключ хранится в DATA_DIR/.mfa_encryption_key и включается в локальные ZIP-копии. Храните резервные копии в безопасном месте либо задайте MFA_ENCRYPTION_KEY явно.",
+      decryptionBrokenTitle: "Отсутствует ключ шифрования",
+      decryptionBrokenError: "Невозможно расшифровать записей: {{count}}, поскольку ключ шифрования больше недоступен. Для восстановления верните прежний MFA_ENCRYPTION_KEY или файл DATA_DIR/.mfa_encryption_key.",
+      migrationErrorWarning: "Не удалось повторно зашифровать старых записей при запуске: {{count}}. Проверьте журнал сервера и перезапустите Bambuddy для повторной попытки.",
+    },
+    pipelineLimits: {
+      title: "Ограничения конвейеров слайсера",
+      maxCopiesLabel: "Максимум копий за запуск",
+      maxCopiesDesc: "Верхний предел количества копий, которое оператор может запросить при запуске конвейера. Жёсткое серверное ограничение — 1000.",
+    },
+    pipelines: {
+      title: "Конвейеры слайсера",
+      subtitle: "Повторно используемые наборы профилей: принтер, процесс, филаменты и тип стола. Сохраните набор в окне нарезки и применяйте его к следующему файлу одним нажатием.",
+      loading: "Загрузка конвейеров...",
+      loadError: "Не удалось загрузить конвейеры.",
+      confirmDelete: "Удалить этот конвейер? Отменить действие нельзя.",
+      staleWarning: "Один или несколько связанных профилей больше не существуют. Чтобы исправить конвейер, повторно сохраните его из окна нарезки.",
+      empty: {
+        title: "Конвейеров пока нет.",
+        howto: "Откройте окно нарезки любого файла, выберите принтер, процесс, филаменты и тип стола, затем нажмите «Сохранить как конвейер». Сохранённые конвейеры появятся здесь.",
+      },
+      field: {
+        name: "Название конвейера",
+        description: "Описание",
+        targetPrinter: "Целевой принтер",
+        noTarget: "— Цель не задана —",
+        targetKind: "Тип цели",
+        targetKindSpecific: "Конкретный принтер",
+        targetKindClass: "Класс принтеров",
+        targetModelClass: "Модель принтера",
+        fanoutStrategy: "Стратегия распределения",
+        fanout: {
+          max_parallel: "Максимум параллельно — распределять между любыми свободными подходящими принтерами",
+          round_robin: "По кругу — последовательно чередовать подходящие принтеры",
+          fill_one_first: "Сначала один — назначить все копии одному принтеру",
+        },
+        fanoutShort: {
+          max_parallel: "параллельно",
+          round_robin: "по кругу",
+          fill_one_first: "сначала один",
+        },
+      },
+      action: {
+        save: "Сохранить",
+        cancel: "Отмена",
+        rename: "Переименовать",
+        delete: "Удалить",
+      },
+      slot: {
+        printer: "Принтер",
+        process: "Процесс",
+        filament: "Филамент",
+        filamentN: "Филамент {{n}}",
+        filamentAll: "Все слоты: {{n}}",
+        bed: "Стол",
+      },
+      group: {
+        profiles: "Профили",
+        filaments: "Филаменты",
+      },
+      searchPlaceholder: "Поиск конвейеров...",
+      filterTargetType: "Фильтр по типу цели",
+      filterTarget: "Фильтр по цели",
+      filter: {
+        all: "Все цели",
+        noTarget: "Цель не задана",
+        count: "{{shown}} из {{total}}",
+        noMatches: "Нет конвейеров, соответствующих текущим фильтрам.",
+      },
+      toast: {
+        saved: "Конвейер сохранён",
+        saveFailed: "Не удалось сохранить",
+        deleted: "Конвейер удалён",
+        deleteFailed: "Не удалось удалить",
+      },
+      noTargetHint: "Чтобы запустить, задайте целевой принтер",
+      noTargetWarning: "Перед запуском конвейера задайте целевой принтер.",
+      runs: {
+        lastRun: "Последний запуск",
+        status: {
+          queued: "в очереди",
+          slicing: "нарезка",
+          dispatching: "назначение",
+          in_progress: "печать",
+          completed: "завершено",
+          failed: "ошибка",
+          partial_failure: "частичная ошибка",
+          cancelled: "отменено",
+        },
+      },
+    },
+  },
+  notification: {
+    printStarted: {
+      title: "Печать началась",
+      body: "{{printer}}: началась печать файла {{filename}}",
+    },
+    printCompleted: {
+      title: "Печать завершена",
+      body: "{{printer}}: файл {{filename}} успешно напечатан",
+    },
+    printFailed: {
+      title: "Ошибка печати",
+      body: "{{printer}}: печать файла {{filename}} завершилась ошибкой",
+    },
+    printStopped: {
+      title: "Печать остановлена",
+      body: "{{printer}}: печать файла {{filename}} остановлена",
+    },
+    printProgress: {
+      title: "Ход печати",
+      body: "{{printer}}: файл {{filename}} напечатан на {{percent}}%",
+    },
+    printerOffline: {
+      title: "Принтер не в сети",
+      body: "{{printer}} не в сети",
+    },
+    printerError: {
+      title: "Ошибка принтера",
+      body: "{{printer}}: {{error}}",
+    },
+    filamentLow: {
+      title: "Заканчивается филамент",
+      body: "{{printer}}: осталось мало филамента",
+    },
+    maintenanceDue: {
+      title: "Требуется обслуживание",
+      body: "{{printer}}: требуют внимания: {{items}}",
+    },
+  },
+  errors: {
+    generic: "Что-то пошло не так",
+    networkError: "Ошибка сети. Проверьте подключение.",
+    notFound: "Не найдено",
+    unauthorized: "Доступ не разрешён",
+    serverError: "Ошибка сервера",
+    validationError: "Проверьте введённые данные",
+    printerConnectionFailed: "Не удалось подключиться к принтеру",
+    saveFailed: "Не удалось сохранить изменения",
+    deleteFailed: "Не удалось удалить",
+    loadFailed: "Не удалось загрузить данные",
+  },
+  hmsErrors: {
+    title: "Ошибки — {{name}}",
+    noErrors: "Ошибок нет",
+    viewOnWiki: "Открыть в Bambu Lab Wiki",
+    unknownCode: "Неизвестный код HMS — подробности см. в Bambu Lab Wiki.",
+    clearInstructions: "Устраните ошибки на принтере, чтобы они исчезли из этого списка.",
+    clearErrors: "Очистить ошибки",
+    clearSuccess: "Ошибки HMS очищены",
+    clearFailed: "Не удалось очистить ошибки HMS",
+    actionSuccess: "Команда отправлена принтеру",
+    actionFailed: "Не удалось отправить команду",
+    runoutExpectedSlot: "Филамент закончился в {{ranOut}}. Теперь принтер ожидает совместимый филамент в {{expected}}. Установите катушку в {{expected}}, затем выберите «Повторить».",
+    runoutExpectedSlotOnly: "Принтер ожидает совместимый филамент в {{expected}}. Установите туда катушку, затем выберите «Повторить».",
+    runoutSlotUnknown: "Филамент закончился, и печать приостановлена. Bambuddy не смог определить, какой слот теперь ожидает принтер. Проверьте запрошенный слот на экране принтера.",
+    actions: {
+      RESUME_PRINTING: "Продолжить печать",
+      RESUME_PRINTING_DEFECTS: "Продолжить (дефекты допустимы)",
+      RESUME_PRINTING_PROBELM_SOLVED: "Продолжить (проблема устранена)",
+      STOP_PRINTING: "Остановить печать",
+      CHECK_ASSISTANT: "Проверить помощник",
+      FILAMENT_EXTRUDED: "Филамент выдавливается — продолжить",
+      RETRY_FILAMENT_EXTRUDED: "Филамент ещё не выходит — повторить",
+      CONTINUE: "Готово — продолжить",
+      LOAD_VIRTUAL_TRAY: "Загрузить филамент",
+      OK_BUTTON: "ОК",
+      FILAMENT_LOAD_RESUME: "Филамент загружен — продолжить",
+      JUMP_TO_LIVEVIEW: "Открыть камеру",
+      NO_REMINDER_NEXT_TIME: "Больше не напоминать",
+      REFRESH_NOZZLE: "Проверить ещё раз",
+      IGNORE_NO_REMINDER_NEXT_TIME: "Игнорировать и больше не напоминать",
+      IGNORE_RESUME: "Игнорировать и продолжить",
+      PROBLEM_SOLVED_RESUME: "Проблема устранена — продолжить",
+      TURN_OFF_FIRE_ALARM: "Понятно, отключить пожарную тревогу",
+      RETRY_PROBLEM_SOLVED: "Повторить (проблема устранена)",
+      CANCLE: "Отмена",
+      STOP_DRYING: "Остановить сушку",
+      PROCEED: "Продолжить",
+      OK_JUMP_RACK: "ОК",
+      ABORT: "Прервать",
+      DISABLE_PURIFICATION: "Отключить очистку воздуха для этой печати",
+      DONT_REMIND_NEXT_TIME: "Больше не напоминать",
+      DBL_CHECK_CANCEL: "Отмена",
+      DBL_CHECK_DONE: "Готово",
+      DBL_CHECK_RETRY: "Повторить",
+      DBL_CHECK_RESUME: "Продолжить",
+      DBL_CHECK_OK: "Подтвердить",
+      REMOVE_CLOSE_BTN: "Закрыть",
+    },
+  },
+  mqttDebug: {
+    title: "Журнал отладки MQTT",
+    searchPlaceholder: "Поиск по теме или содержимому...",
+    noMessages: "Сообщений пока нет",
+    startLoggingHint: "Нажмите «Начать запись», чтобы перехватывать сообщения MQTT",
+    noMessagesMatch: "Нет сообщений, соответствующих фильтру",
+    adjustFilterHint: "Измените запрос или параметры фильтра",
+    incoming: "Входящие",
+    outgoing: "Исходящие",
+    loggingStopped: "Запись остановлена",
+    loggingActive: "Запись активна — сообщения обновляются автоматически",
+    startLogging: "Начать запись",
+    stopLogging: "Остановить запись",
+    clearLog: "Очистить журнал",
+    topic: "Тема",
+    timestamp: "Время",
+    direction: "Направление",
+    all: "Все",
+  },
+  printerFiles: {
+    title: "Файлы принтера",
+    storageUsed: "Занято:",
+    storageFree: "Свободно:",
+    filterPlaceholder: "Фильтр файлов...",
+    deleteButton: "Удалить",
+    deleteFiles: "Удалить файлы: {{count}}",
+    deleteFileConfirm: "Удалить «{{name}}»? Это действие нельзя отменить.",
+    deleteFilesConfirm: "Удалить выбранные файлы ({{count}})? Это действие нельзя отменить.",
+    noFiles: "На принтере нет файлов",
+    loadingFiles: "Загрузка файлов...",
+    failedToLoad: "Не удалось загрузить файлы",
+    toast: {
+      filesDeleted: "Удалено файлов: {{count}}",
+      deleteFailed: "Не удалось удалить: {{error}}",
+    },
+  },
+  confirm: {
+    delete: "Удалить этот объект?",
+    unsavedChanges: "Есть несохранённые изменения. Всё равно уйти со страницы?",
+    clearQueue: "Очистить очередь печати?",
+  },
+  login: {
+    title: "Вход в Bambuddy",
+    subtitle: "Войдите в свою учётную запись",
+    username: "Имя пользователя",
+    usernamePlaceholder: "Введите имя пользователя",
+    usernameOrEmail: "Имя пользователя или email",
+    usernameOrEmailPlaceholder: "Имя пользователя или email",
+    password: "Пароль",
+    passwordPlaceholder: "Введите пароль",
+    signIn: "Войти",
+    signingIn: "Вход...",
+    rememberMe: "Запомнить меня",
+    forgotPassword: "Забыли пароль?",
+    autologinFailed: "Автоматический вход через SSO не выполнен. Выберите провайдера ниже.",
+    localDisabledNotice: "Локальный вход отключён. Используйте одного из SSO-провайдеров ниже.",
+    loginSuccess: "Вход выполнен",
+    loginFailed: "Не удалось войти",
+    enterCredentials: "Введите имя пользователя и пароль",
+    enterEmail: "Введите адрес электронной почты",
+    oidcLoginFailed: "Ошибка входа через OIDC",
+    oidcErrors: {
+      providerError: "Провайдер удостоверений вернул ошибку",
+      missingParameters: "В ответе OIDC отсутствуют обязательные параметры",
+      invalidState: "Состояние OIDC недействительно или уже использовано",
+      stateExpired: "Сеанс входа OIDC истёк — повторите попытку",
+      providerNotFound: "Провайдер OIDC не найден",
+      discoveryFailed: "Не удалось получить документ обнаружения OIDC",
+      invalidDiscovery: "Документ обнаружения OIDC недействителен",
+      networkError: "Сетевая ошибка при обмене токена OIDC",
+      badResponse: "Неожиданный ответ при обмене токена OIDC",
+      noIdToken: "Провайдер OIDC не вернул ID-токен",
+      validationFailed: "Не удалось проверить токен OIDC",
+      nonceMismatch: "Несовпадение nonce OIDC — возможна атака повторного воспроизведения",
+      missingSubClaim: "В токене OIDC отсутствует поле sub",
+      noLinkedAccount: "С этой учётной записью OIDC не связана локальная учётная запись",
+      accountInactive: "Учётная запись неактивна",
+      userResolutionFailed: "Не удалось определить учётную запись",
+      internalError: "Внутренняя ошибка при входе через OIDC",
+      tokenExchangeFailed: "Не удалось обменять токен OIDC",
+    },
+    forgotPasswordTitle: "Восстановление пароля",
+    forgotPasswordMessage: "Обратитесь к системному администратору для сброса пароля.",
+    forgotPasswordEmailMessage: "Введите адрес электронной почты, и мы отправим новый пароль.",
+    emailAddress: "Адрес электронной почты",
+    emailPlaceholder: "your.email@example.com",
+    cancel: "Отмена",
+    sending: "Отправка...",
+    sendResetEmail: "Отправить письмо для сброса",
+    howToReset: "Как сбросить пароль:",
+    resetStep1: "Свяжитесь с администратором Bambuddy",
+    resetStep2: "Попросите сбросить пароль в разделе управления пользователями",
+    resetStep3: "Администратор задаст новый временный пароль",
+    resetStep4: "Войдите с новым паролем и измените его в настройках",
+    gotIt: "Понятно",
+    resetPassword: {
+      title: "Новый пароль",
+      subtitle: "Введите и подтвердите новый пароль.",
+      newPassword: "Новый пароль",
+      newPasswordPlaceholder: "Не менее 8 символов",
+      confirmPassword: "Подтверждение пароля",
+      confirmPasswordPlaceholder: "Повторите новый пароль",
+      saving: "Сохранение…",
+      submit: "Установить новый пароль",
+      backToLogin: "Вернуться ко входу",
+      passwordsDoNotMatch: "Пароли не совпадают",
+      passwordTooShort: "Пароль должен содержать не менее 8 символов",
+      resetFailed: "Не удалось сбросить пароль. Возможно, ссылка устарела.",
+    },
+    twoFA: {
+      title: "Двухфакторная аутентификация",
+      subtitle: "Учётная запись защищена 2FA. Введите код подтверждения.",
+      methodAuthenticator: "Приложение-аутентификатор",
+      methodEmail: "Код по email",
+      methodBackup: "Резервный код",
+      instructionsTotp: "Откройте приложение-аутентификатор и введите шестизначный код Bambuddy.",
+      instructionsEmail: "Шестизначный код отправлен на вашу электронную почту. Он действует 10 минут.",
+      instructionsEmailNotSent: "Нажмите кнопку ниже, чтобы получить код подтверждения по email.",
+      instructionsBackup: "Введите один из восьмизначных резервных кодов восстановления. Каждый код используется только один раз.",
+      sendCodeButton: "Отправить код по email",
+      sendingCode: "Отправка...",
+      resendCode: "Отправить код повторно",
+      codeLabel: "Код подтверждения",
+      backupCodeLabel: "Резервный код",
+      codePlaceholder: "000000",
+      backupCodePlaceholder: "XXXXXXXX",
+      verifyButton: "Подтвердить",
+      verifyingButton: "Проверка...",
+      backToLogin: "← Вернуться ко входу",
+      orContinueWith: "или продолжить через",
+      signInWith: "Войти через {{provider}}",
+      enterCode: "Введите код подтверждения",
+      sendCodeFailed: "Не удалось отправить код подтверждения",
+      invalidCode: "Неверный код. Повторите попытку.",
+    },
+  },
+  setup: {
+    title: "Настройка Bambuddy",
+    subtitle: "Настройте аутентификацию для этого экземпляра Bambuddy",
+    enableAuth: "Включить аутентификацию",
+    adminAccount: "Учётная запись администратора",
+    adminAccountDesc: "Если администраторы уже существуют, аутентификация будет включена с использованием существующих учётных записей. Оставьте поля пустыми или введите данные для создания нового администратора.",
+    adminUsername: "Имя администратора",
+    adminPassword: "Пароль администратора",
+    optionalIfAdminExists: "(необязательно, если администратор уже существует)",
+    adminUsernamePlaceholder: "Введите имя администратора (необязательно)",
+    adminPasswordPlaceholder: "Введите пароль администратора (необязательно)",
+    confirmPassword: "Подтвердите пароль",
+    confirmPasswordPlaceholder: "Повторите пароль администратора",
+    settingUp: "Настройка...",
+    completeSetup: "Завершить настройку",
+    toast: {
+      authEnabledAdminCreated: "Аутентификация включена, администратор создан",
+      authEnabledExistingAdmins: "Аутентификация включена для существующих администраторов",
+      setupCompleted: "Настройка завершена",
+      enterBothCredentials: "Введите имя и пароль администратора либо оставьте оба поля пустыми",
+      passwordsDoNotMatch: "Пароли не совпадают",
+      passwordTooShort: "Пароль должен содержать не менее 6 символов",
+    },
+  },
+  changePassword: {
+    title: "Изменение пароля",
+    currentPassword: "Текущий пароль",
+    currentPasswordPlaceholder: "Введите текущий пароль",
+    newPassword: "Новый пароль",
+    newPasswordPlaceholder: "Введите новый пароль (не менее 6 символов)",
+    confirmPassword: "Подтвердите новый пароль",
+    confirmPasswordPlaceholder: "Повторите новый пароль",
+    passwordsDoNotMatch: "Пароли не совпадают",
+    passwordTooShort: "Пароль должен содержать не менее 6 символов",
+    changing: "Изменение...",
+    success: "Пароль изменён",
+    failed: "Не удалось изменить пароль",
+  },
+  plateAlert: {
+    title: "Печать приостановлена!",
+    message: "На печатной пластине обнаружены объекты. Печать автоматически приостановлена. Очистите пластину и продолжите печать.",
+    understand: "Понятно",
+  },
+  camera: {
+    title: "Камера",
+    invalidPrinterId: "Неверный идентификатор принтера",
+    live: "Прямой эфир",
+    snapshot: "Снимок",
+    restartStream: "Перезапустить поток",
+    refreshSnapshot: "Обновить снимок",
+    fullscreen: "На весь экран",
+    exitFullscreen: "Выйти из полноэкранного режима",
+    connectingToCamera: "Подключение к камере...",
+    capturingSnapshot: "Создание снимка...",
+    connectionLost: "Соединение потеряно",
+    connectionFailed: "Не удалось подключиться к камере",
+    reconnecting: "Повторное подключение через {{countdown}} с... (попытка {{attempt}} из {{max}})",
+    reconnectNow: "Подключиться сейчас",
+    cameraUnavailable: "Камера недоступна",
+    cameraUnavailableDesc: "Убедитесь, что принтер включён и подключён к сети.",
+    noCamera: "Камера отсутствует",
+    retry: "Повторить",
+    cameraStream: "Видеопоток камеры",
+    zoomOut: "Уменьшить",
+    zoomIn: "Увеличить",
+    resetZoom: "Сбросить масштаб",
+    recording: "Запись",
+    startRecording: "Начать запись",
+    stopRecording: "Остановить запись",
+    chamberLight: "Переключить освещение камеры принтера",
+    unavailable: "Камера недоступна",
+    diagnose: {
+      button: "Диагностика",
+      modalTitle: "Диагностика камеры",
+      running: "Выполняется диагностика...",
+      runFailed: "Не удалось выполнить диагностику: {{error}}",
+      retry: "Запустить снова",
+      stage: {
+        tcp_reachable: "Доступность по сети",
+        first_frame: "Получение кадра",
+        live_stream_active: "Активный видеопоток",
+      },
+      summary: {
+        all_ok: "Камера работает. Все этапы диагностики завершены успешно.",
+        live_stream_active_healthy: "Камера уже передаёт свежие кадры — дополнительная проверка не требуется.",
+        printer_unreachable: "Принтер недоступен. Проверьте IP-адрес, сетевое подключение и питание принтера.",
+        camera_port_closed: "Принтер доступен, но порт камеры закрыт. Убедитесь, что в настройках принтера включены режим «Только LAN» и режим разработчика.",
+        no_frame: "Подключение к камере установлено, но кадры не поступают. Повторите попытку или проверьте, включена ли камера в настройках принтера.",
+        unknown_failure: "Диагностика камеры завершилась неизвестной ошибкой. Подробности см. в журнале поддержки.",
+      },
+      meta: {
+        protocol: "Протокол",
+        port: "Порт",
+        profile: "Профиль",
+      },
+    },
+  },
+  groups: {
+    title: "Управление группами",
+    subtitle: "Настройка групп разрешений для контроля доступа",
+    backToSettings: "Вернуться к настройкам",
+    createGroup: "Создать группу",
+    noPermission: "У вас нет доступа к этой странице.",
+    system: "Системная",
+    noDescription: "Без описания",
+    usersCount: "Пользователей: {{count}}",
+    permissionsCount: "Разрешений: {{count}}",
+    edit: "Изменить",
+    delete: "Удалить",
+    toast: {
+      created: "Группа создана",
+      updated: "Группа обновлена",
+      deleted: "Группа удалена",
+      enterGroupName: "Введите название группы",
+    },
+    modal: {
+      editGroup: "Изменение группы",
+      createGroup: "Создание группы",
+      cancel: "Отмена",
+      saving: "Сохранение...",
+      creating: "Создание...",
+      saveChanges: "Сохранить изменения",
+    },
+    form: {
+      groupName: "Название группы",
+      groupNamePlaceholder: "Введите название группы",
+      systemGroupWarning: "Названия системных групп изменить нельзя",
+      description: "Описание",
+      descriptionPlaceholder: "Введите описание (необязательно)",
+      permissions: "Разрешения (выбрано: {{count}})",
+    },
+    deleteModal: {
+      title: "Удаление группы",
+      message: "Удалить эту группу? Входящие в неё пользователи потеряют соответствующие разрешения.",
+      confirm: "Удалить группу",
+    },
+    editor: {
+      title: "Изменение группы",
+      createTitle: "Создание группы",
+      search: "Поиск разрешений...",
+      selectAll: "Выбрать все",
+      clearAll: "Очистить выбор",
+      permissionsSelected: "Выбрано: {{count}}",
+      noResults: "Нет разрешений, соответствующих запросу",
+      websocketHint: "Необходимо для обновлений в реальном времени. Без этого интерфейс будет периодически опрашивать сервер.",
+    },
+  },
+  users: {
+    title: "Управление пользователями",
+    subtitle: "Управление пользователями и их доступом к Bambuddy",
+    backToSettings: "Вернуться к настройкам",
+    createUser: "Создать пользователя",
+    noPermission: "У вас нет доступа к этой странице.",
+    admin: "Администратор",
+    noGroups: "Без групп",
+    active: "Активен",
+    inactive: "Отключён",
+    edit: "Изменить",
+    delete: "Удалить",
+    system: "Системный",
+    noGroupsAvailable: "Нет доступных групп",
+    table: {
+      username: "Имя пользователя",
+      groups: "Группы",
+      status: "Состояние",
+      actions: "Действия",
+    },
+    toast: {
+      created: "Пользователь создан",
+      updated: "Пользователь обновлён",
+      deleted: "Пользователь удалён",
+      fillRequired: "Заполните все обязательные поля",
+      passwordsDoNotMatch: "Пароли не совпадают",
+      passwordTooShort: "Пароль должен содержать не менее 6 символов",
+      ldapProvisioned: "Пользователь LDAP «{{username}}» добавлен",
+    },
+    modal: {
+      createUser: "Создание пользователя",
+      editUser: "Изменение пользователя",
+      cancel: "Отмена",
+      creating: "Создание...",
+      saving: "Сохранение...",
+      saveChanges: "Сохранить изменения",
+      advancedAuthSubtitle: "с расширенной аутентификацией",
+      tabsAriaLabel: "Источник пользователя",
+      localTab: "Локальный",
+      ldapTab: "LDAP",
+      ldapSearchLabel: "Поиск в каталоге",
+      ldapSearchPlaceholder: "Введите имя пользователя, имя или email...",
+      ldapMinChars: "Для поиска введите не менее 2 символов",
+      ldapTypeToSearch: "Начните вводить запрос для поиска в каталоге LDAP",
+      ldapSearching: "Поиск в каталоге...",
+      ldapNoResults: "В каталоге нет подходящих пользователей",
+      ldapSearchError: "Ошибка поиска в каталоге. Проверьте состояние сервера LDAP.",
+      ldapAlreadyProvisioned: "Уже добавлен",
+      ldapSelectedLabel: "Выбран",
+      ldapProvision: "Добавить пользователя",
+      ldapProvisioning: "Добавление...",
+      ldapErrorProvision: "Не удалось добавить пользователя. Проверьте сервер LDAP и повторите попытку.",
+    },
+    form: {
+      username: "Имя пользователя",
+      usernamePlaceholder: "Введите имя пользователя",
+      email: "Электронная почта",
+      emailPlaceholder: "user@example.com",
+      password: "Пароль",
+      passwordPlaceholder: "Введите пароль",
+      confirmPassword: "Подтверждение пароля",
+      confirmPasswordPlaceholder: "Повторите пароль",
+      newPasswordPlaceholder: "Введите новый пароль",
+      confirmNewPasswordPlaceholder: "Повторите новый пароль",
+      leaveBlankToKeep: "оставьте пустым, чтобы сохранить текущий",
+      groups: "Группы",
+      optional: "необязательно",
+      autoGeneratedPassword: "Надёжный пароль будет создан автоматически и отправлен пользователю по email.",
+      passwordManagedByAdvancedAuth: "Пароль управляется расширенной аутентификацией. Используйте «Сбросить пароль», чтобы отправить пользователю новый пароль по email.",
+      resetPassword: "Сбросить пароль",
+      resettingPassword: "Сброс пароля...",
+    },
+    deleteModal: {
+      title: "Удаление пользователя",
+      message: "Удалить этого пользователя? Это действие нельзя отменить.",
+      confirm: "Удалить пользователя",
+    },
+  },
+  streamOverlay: {
+    title: "Оформление трансляции",
+    invalidPrinterId: "Неверный идентификатор принтера",
+    cameraStream: "Видеопоток камеры",
+    progress: "Прогресс",
+    eta: "Осталось",
+    printerIdle: "Принтер простаивает",
+    printerOffline: "Принтер не в сети",
+    status: {
+      printing: "Печать",
+      paused: "Приостановлено",
+      finished: "Завершено",
+      failed: "Ошибка",
+      idle: "Ожидание",
+      unknown: "Неизвестно",
+    },
+  },
+  profiles: {
+    title: "Профили",
+    subtitle: "Управление профилями слайсера и калибровками Pressure Advance",
+    tabs: {
+      bambuCloud: "Bambu Cloud",
+      orcaCloud: "Orca Cloud",
+      local: "Локальные профили",
+      kprofiles: "K-профили",
+    },
+    orcaCloud: {
+      connectedAs: "Подключено как",
+      connectedShort: "Подключено к Orca Cloud",
+      logout: "Отключиться",
+      noLogoutPermission: "У вас нет разрешения отключаться от Orca Cloud",
+      noConnectPermission: "У вас нет разрешения подключаться к Orca Cloud",
+      retry: "Повторить",
+      connectButton: "Подключить Orca Cloud",
+      connect: {
+        title: "Подключение к Orca Cloud",
+        description: "Войдите в Orca Cloud, чтобы синхронизировать профили слайсера с Bambuddy.",
+      },
+      device: {
+        title: "Разрешите доступ Bambuddy в Orca Cloud",
+        instruction: "Откройте Orca Cloud и подтвердите этот код. После подтверждения Bambuddy подключится автоматически.",
+        codeLabel: "Код сопряжения",
+        openButton: "Открыть страницу подтверждения Orca Cloud",
+        manualHint: "Либо откройте {{url}} и введите указанный выше код.",
+        waiting: "Ожидание подтверждения…",
+        cancel: "Отмена",
+      },
+      profiles: {
+        title: "Ваши профили Orca Cloud ({{count}})",
+        refresh: "Обновить",
+        empty: "В вашей учётной записи Orca Cloud пока нет профилей.",
+      },
+      toast: {
+        disconnected: "Orca Cloud отключён",
+      },
+      errors: {
+        startFailed: "Не удалось начать вход в Orca Cloud.",
+        denied: "Сопряжение отклонено в Orca Cloud.",
+        expired: "Срок действия кода сопряжения истёк. Нажмите «Подключить», чтобы повторить попытку.",
+        pollFailed: "Соединение было потеряно во время ожидания подтверждения. Повторите попытку.",
+      },
+    },
+    localProfiles: {
+      title: "Локальные профили",
+      subtitle: "Импорт профилей OrcaSlicer и управление ими",
+      import: "Импортировать профили",
+      importDesc: "Перетащите сюда файлы .bbscfg, .bbsflmt, .orca_filament, .zip или .json",
+      importing: "Импорт...",
+      search: "Поиск локальных профилей...",
+      noPresets: "Локальных профилей пока нет",
+      noSearchResults: "Нет профилей, соответствующих запросу",
+      badge: "Локальный",
+      edit: "Изменить",
+      delete: "Удалить",
+      cancel: "Отмена",
+      deleteConfirmTitle: "Удаление профиля",
+      deleteConfirm: "Удалить этот профиль? Это действие нельзя отменить.",
+      source: "Источник",
+      inheritsFrom: "Наследует",
+      filamentType: "Тип",
+      vendor: "Производитель",
+      compatiblePrinters: "Принтеры",
+      nozzleTemp: "Температура сопла",
+      cost: "Стоимость",
+      density: "Плотность",
+      pressureAdvance: "Опережение давления",
+      filament: "Филамент",
+      process: "Процесс",
+      printer: "Принтер",
+      toast: {
+        importSuccess: "Импортировано профилей: {{count}}",
+        importSkipped: "Пропущено дубликатов: {{count}}",
+        importError: "Ошибок при импорте: {{count}}",
+        deleted: "Профиль удалён",
+        updated: "Профиль обновлён",
+      },
+    },
+    connectedAs: "Подключено как",
+    signInExpiredTitle: "Срок авторизации в Bambu Cloud истёк",
+    signInExpiredBody: "Bambu Lab больше не принимает сохранённый токен. Выполните вход снова, чтобы восстановить облачные профили, импорт из MakerWorld и проверку прошивок.",
+    logout: "Выйти",
+    noLogoutPermission: "У вас нет разрешения выходить из учётной записи",
+    failedToLoad: "Не удалось загрузить профили",
+    retry: "Повторить",
+    time: {
+      justNow: "Только что",
+      minsAgo: "{{count}} мин назад",
+      hoursAgo: "{{count}} ч назад",
+      daysAgo: "{{count}} дн назад",
+    },
+    toast: {
+      loggedOut: "Вы вышли из учётной записи",
+    },
+    login: {
+      title: "Подключение к Bambu Cloud",
+      subtitle: "Синхронизация профилей слайсера между устройствами",
+      email: "Электронная почта",
+      password: "Пароль",
+      region: "Регион",
+      regionGlobal: "Глобальный",
+      regionChina: "Китай",
+      verificationCode: "Код подтверждения",
+      totpCode: "Код приложения-аутентификатора",
+      checkEmail: "Проверьте почту {{email}}: туда отправлен шестизначный код",
+      enterTotpHint: "Введите шестизначный код из приложения-аутентификатора",
+      accessToken: "Токен доступа",
+      accessTokenHint: "Вставьте токен доступа Bambu Cloud. Для учётных записей китайского региона, привязанных к телефону, доступен только этот способ: вход по email не поддерживается. Инструкцию по получению токена из cookie MakerWorld см. в wiki.",
+      back: "Назад",
+      loginButton: "Войти",
+      verifyButton: "Подтвердить",
+      setTokenButton: "Сохранить токен",
+      useToken: "Использовать токен доступа",
+      useEmail: "Войти по email",
+      toast: {
+        loggedIn: "Вход выполнен",
+        codeSent: "Код подтверждения отправлен по email",
+        enterTotp: "Введите код из приложения-аутентификатора",
+        tokenSet: "Токен сохранён",
+      },
+    },
+    presets: {
+      myPreset: "Мой профиль (редактируемый)",
+      duplicate: "Создать копию",
+      editable: "Редактируемый",
+      failedToLoadDetails: "Не удалось загрузить сведения о профиле",
+      deleteConfirm: "Удалить этот профиль?",
+      deleteWarning: "Профиль «{{name}}» будет безвозвратно удалён из Bambu Cloud. Это действие нельзя отменить.",
+      noDuplicatePermission: "У вас нет разрешения копировать профили",
+      noEditPermission: "У вас нет разрешения редактировать профили",
+      noDeletePermission: "У вас нет разрешения удалять профили",
+      types: {
+        filament: "Профиль филамента",
+        printer: "Профиль принтера",
+        process: "Профиль процесса",
+      },
+      toast: {
+        deleted: "Профиль удалён",
+        created: "Профиль создан",
+        updated: "Профиль обновлён",
+        duplicated: "Копия профиля создана",
+        fieldAdded: "Добавлено поле «{{key}}»",
+        exported: "Профиль экспортирован",
+      },
+      baseLabel: "Базовый: {{name}}",
+      currentLabel: "Текущий: {{name}}",
+      newPreset: "Новый профиль",
+      editPreset: "Изменение профиля",
+      duplicatePreset: "Копирование профиля",
+      createNewPreset: "Создание профиля",
+      customizeSettings: "Настройте параметры нового профиля",
+      compareWithBase: "Сравнить с базовым профилем",
+      compare: "Сравнить",
+      basePreset: "Базовый профиль",
+      selectBasePreset: "Выберите базовый профиль...",
+      presetName: "Название профиля",
+      myCustomPreset: "Мой профиль",
+      inheritsFrom: "Наследует от",
+      dropJsonToImport: "Перетащите JSON для импорта",
+      tabs: {
+        common: "Основные",
+        allFields: "Все поля",
+      },
+      availableFields: "Доступные поля",
+      searchFieldsPlaceholder: "Поиск полей...",
+      noMatchingFields: "Нет подходящих полей",
+      allFieldsAdded: "Все поля добавлены",
+      addCustomField: "Добавить произвольное поле",
+      yourOverrides: "Ваши переопределения",
+      noOverridesYet: "Переопределений пока нет",
+      clickFieldsToAdd: "Нажимайте поля слева, чтобы добавить их",
+      saveAsTemplate: "Сохранить как шаблон",
+      jsonTip: "Совет: перетащите файл .json в это окно, чтобы импортировать настройки",
+    },
+    cloudView: {
+      searchPlaceholder: "Поиск профилей...",
+      templates: "Шаблоны",
+      refresh: "Обновить",
+      newPreset: "Новый профиль",
+      clearFilters: "Сбросить фильтры",
+      compareMode: "Режим сравнения",
+      selectAnotherPreset: "Выберите другой профиль типа «{{type}}»",
+      clickTwoPresets: "Выберите два профиля одного типа для сравнения",
+      selectFirst: "1. Выберите первый",
+      selectSecond: "2. Выберите второй",
+      compareNow: "Сравнить",
+      lastSynced: "Последняя синхронизация:",
+      showingCount: "Показано {{showing}} из {{total}} профилей",
+      noPresetsFound: "Профили не найдены",
+      columns: {
+        filament: "Филамент",
+        process: "Процесс",
+        printer: "Принтер",
+      },
+      noFilamentPresets: "Нет профилей филамента",
+      noProcessPresets: "Нет профилей процесса",
+      noPrinterPresets: "Нет профилей принтера",
+      filters: {
+        type: "Тип",
+        owner: "Владелец",
+        printer: "Принтер",
+        nozzle: "Сопло",
+        filament: "Филамент",
+        layer: "Слой",
+        all: "Все",
+        myPresets: "Мои профили",
+        builtIn: "Встроенные",
+        process: "Процесс",
+      },
+      noTemplatesPermission: "У вас нет разрешения управлять шаблонами",
+      noRefreshPermission: "У вас нет разрешения обновлять профили",
+      noCreatePermission: "У вас нет разрешения создавать профили",
+    },
+    templates: {
+      title: "Быстрые шаблоны",
+      noTemplates: "Шаблонов пока нет",
+      createFirst: "Создавайте шаблоны в редакторе профилей",
+      typeFilter: "Тип:",
+      deleteTitle: "Удаление шаблона",
+      deleteWarning: "Это действие нельзя отменить",
+      deleteConfirm: "Удалить шаблон «{{name}}»?",
+      namePlaceholder: "Название шаблона",
+      descriptionPlaceholder: "Описание",
+      settingsJson: "Настройки (JSON)",
+      fieldsCount: "Полей: {{count}}",
+      shownInModals: "Показывается в окнах",
+      hiddenInModals: "Скрыт в окнах",
+      apply: "Применить",
+      toast: {
+        deleted: "Шаблон удалён",
+        updated: "Шаблон обновлён",
+        created: "Шаблон создан",
+        applied: "Шаблон применён",
+      },
+    },
+  },
+  support: {
+    debugLoggingActive: "Отладочное журналирование включено",
+    manageLogs: "Управление",
+    collectItem7: "Подключение принтеров и версии прошивок",
+    collectItem8: "Состояние интеграций (Spoolman, MQTT, HA)",
+    collectItem9: "Сетевые интерфейсы (только подсети)",
+    collectItem10: "Версии пакетов Python",
+    collectItem11: "Проверка состояния базы данных",
+    collectItem12: "Сведения о среде Docker",
+    bundleGenerating: "Формирование пакета...",
+    bundleStepConnection: "Проверка подключения принтеров",
+    bundleStepVirtualPrinters: "Проверка настройки виртуальных принтеров",
+    bundleStepLogScan: "Поиск известных проблем в последних журналах",
+    bundleStepBuild: "Создание ZIP-пакета для поддержки",
+  },
+  fileManager: {
+    title: "Файловый менеджер",
+    subtitle: "Организация и управление файлами для печати",
+    uploadFiles: "Загрузить файлы",
+    newFolder: "Новая папка",
+    folderName: "Имя папки",
+    folderNamePlaceholder: "например, Функциональные детали",
+    renameFile: "Переименовать файл",
+    renameFolder: "Переименовать папку",
+    invalidFilenameChar: "Символ «{{char}}» нельзя использовать в именах файлов печати. SD-карта принтера не принимает: < > : \" / \\ | ? *",
+    moveFiles: "Переместить файлы ({{count}})",
+    rootNoFolder: "Корень (без папки)",
+    current: "текущая",
+    linkFolder: "Связать папку",
+    linkFolderDescription: "Связать «{{name}}» с проектом или архивом для быстрого доступа.",
+    project: "Проект",
+    archive: "Архив",
+    noProjectsFound: "Проекты не найдены",
+    noArchivesFound: "Архивы не найдены",
+    unlink: "Отвязать",
+    link: "Связать",
+    dragDropFiles: "Перетащите файлы сюда",
+    dropFilesHere: "Перетащите файлы сюда",
+    releaseToUpload: "Отпустите для загрузки",
+    orClickToBrowse: "или нажмите для выбора",
+    allFileTypesSupported: "Поддерживаются все типы файлов. ZIP-архивы будут распакованы.",
+    zipFilesDetected: "Обнаружены ZIP-архивы",
+    zipExtractOptions: "ZIP-архивы будут распакованы. Выберите способ обработки структуры папок:",
+    preserveZipStructure: "Сохранить структуру папок из ZIP",
+    createFolderFromZip: "Создать папку с именем ZIP-файла",
+    stlThumbnailGeneration: "Создание миниатюр STL",
+    zipMayContainStl: "ZIP-архивы могут содержать STL-файлы. Миниатюры можно создать при распаковке.",
+    thumbnailsCanBeGenerated: "Для STL-файлов можно создать миниатюры. Обработка крупных моделей может занять больше времени.",
+    generateThumbnailsForStl: "Создать миниатюры для STL-файлов",
+    threemfDetected: "Обнаружены файлы 3MF",
+    threemfExtractionInfo: "Модель принтера, материал, цвет и параметры печати будут автоматически извлечены из файлов 3MF.",
+    willBeExtracted: "Будут извлечены",
+    filesExtracted: "Извлечено файлов: {{count}}",
+    uploadComplete: "Загрузка завершена: успешно — {{succeeded}}",
+    uploadFailed: "Ошибка загрузки",
+    zipFilesFailed: "Не удалось обработать файлов: {{count}}",
+    uploading: "Загрузка...",
+    changeLink: "Изменить связь...",
+    linkTo: "Связать с...",
+    linkToProjectOrArchive: "Связать с проектом или архивом",
+    generateThumbnail: "Создать миниатюру",
+    generateThumbnails: "Создать миниатюры",
+    generateThumbnailsForMissing: "Создать отсутствующие миниатюры для STL-файлов",
+    gridView: "Плитка",
+    listView: "Список",
+    lowDiskSpaceWarning: "Мало свободного места",
+    lowDiskSpaceDetails: "Свободно только {{free}} из {{total}}. Порог предупреждения в настройках: {{threshold}} ГБ.",
+    files: "Файлы",
+    folders: "Папки",
+    size: "Размер",
+    free: "Свободно",
+    allFiles: "Все файлы",
+    allExternal: "Внешние",
+    externalIsEmpty: "Внешних файлов нет",
+    externalEmptyDescription: "Здесь появятся файлы из подключённых внешних папок.",
+    wrap: "Перенос строк",
+    enableTextWrapping: "Включить перенос строк",
+    disableTextWrapping: "Отключить перенос строк",
+    collapse: "Свернуть",
+    collapseFoldersByDefault: "Сворачивать папки по умолчанию",
+    expandFoldersByDefault: "Разворачивать папки по умолчанию",
+    folderSort: "Сортировка папок",
+    folderSortByName: "По имени",
+    folderSortByActivity: "По последней активности",
+    dragToResizeTooltip: "Перетащите для изменения размера, двойной щелчок — сброс",
+    searchFiles: "Поиск файлов...",
+    searchSubfoldersHint: "С учётом вложенных папок",
+    readme: {
+      truncated: "Показано не полностью",
+      show: "Показать README",
+      hide: "Скрыть README",
+      label: "README",
+    },
+    tags: {
+      title: "Метки",
+      subtitle: "Помечайте файлы для сквозной фильтрации: игрушки, безопасно для детей, только PETG и что угодно ещё.",
+      manage: "Метки",
+      manageTitle: "Управление каталогом меток",
+      add: "Новая метка",
+      edit: "Переименовать метку",
+      name: "Название",
+      fileCount: "Файлы",
+      empty: "Меток пока нет. Создайте первую, чтобы начать помечать файлы.",
+      noMatches: "Подходящие метки не найдены.",
+      createPlaceholder: "например, игрушки, детям, petg",
+      createButton: "Создать",
+      nameRequired: "Укажите название.",
+      searchPlaceholder: "Фильтр меток...",
+      created: "Метка создана.",
+      updated: "Метка переименована.",
+      deleted: "Метка удалена.",
+      saveFailed: "Не удалось сохранить метку.",
+      deleteFailed: "Не удалось удалить метку.",
+      applyFailed: "Не удалось применить метки.",
+      applyAdd: "Добавить метки",
+      applyRemove: "Удалить метки",
+      applyAddSuccess: "Добавлено меток: {{count}}; обработано файлов: {{files}}.",
+      applyRemoveSuccess: "Удалено меток: {{count}}; обработано файлов: {{files}}.",
+      actionAdd: "Добавить к выбранным файлам",
+      actionRemove: "Удалить с выбранных файлов",
+      tagAction: "Метки",
+      bulkTitle: "Метки для выбранных файлов ({{count}})",
+      bulkTooltip: "Добавить или удалить метки у всех выбранных файлов.",
+      noPermission: "У вас нет прав на изменение меток файлов.",
+      filterLabel: "Фильтр по меткам:",
+      clearAll: "Очистить всё",
+      confirmDelete: "Удалить метку «{{name}}»?",
+      confirmDeleteMessage: "Метка будет удалена из каталога. Остальные метки файлов сохранятся.",
+      confirmDeleteInUseMessage: "Эта метка назначена файлам ({{count}}). При удалении метка исчезнет у всех файлов; сами файлы не изменятся.",
+      editAria: "Изменить {{name}}",
+      deleteAria: "Удалить {{name}}",
+    },
+    allTypes: "Все типы",
+    prints: "Файлы печати",
+    ascending: "По возрастанию",
+    descending: "По убыванию",
+    resultsCount: "Показано {{showing}} из {{total}} файлов",
+    selectAll: "Выбрать всё",
+    deselectAll: "Снять выделение",
+    selected: "Выбрано: {{count}}",
+    adding: "Добавление...",
+    loadingFiles: "Загрузка файлов...",
+    folderIsEmpty: "Папка пуста",
+    noFilesYet: "Файлов пока нет",
+    folderEmptyDescription: "Загрузите или переместите файлы в эту папку.",
+    noFilesDescription: "Загрузите файлы, чтобы начать упорядочивать материалы для печати.",
+    noMatchingFiles: "Подходящие файлы не найдены",
+    noMatchingFilesDescription: "Нет файлов, соответствующих текущему поиску или фильтрам.",
+    clearFilters: "Сбросить фильтры",
+    printedCount: "Напечатано: {{count}}×",
+    uploadedBy: "Загрузил",
+    deleteFolder: "Удалить папку",
+    deleteFile: "Удалить файл",
+    deleteFilesCount: "Удалить файлы ({{count}})",
+    deleteFolderConfirm: "Удалить эту папку? Все находящиеся в ней файлы также будут удалены.",
+    deleteFileConfirm: "Удалить этот файл?",
+    deleteFilesConfirm: "Удалить выбранные файлы ({{count}})? Это действие нельзя отменить.",
+    deleting: "Удаление...",
+    noPermissionRenameFolder: "У вас нет прав на переименование папок",
+    noPermissionLinkFolder: "У вас нет прав на связывание папок",
+    noPermissionDeleteFolder: "У вас нет прав на удаление папок",
+    noPermissionPrint: "У вас нет прав на запуск печати",
+    noPermissionAddToQueue: "У вас нет прав на добавление в очередь",
+    noPermissionSlice: "У вас нет прав на нарезку файлов",
+    noPermissionDownload: "У вас нет прав на скачивание файлов",
+    noPermissionRenameFile: "У вас нет прав на переименование этого файла",
+    noPermissionGenerateThumbnail: "У вас нет прав на создание миниатюр",
+    noPermissionDeleteFile: "У вас нет прав на удаление этого файла",
+    noPermissionCreateFolder: "У вас нет прав на создание папок",
+    noPermissionUpload: "У вас нет прав на загрузку файлов",
+    noPermissionMoveFiles: "У вас нет прав на перемещение файлов",
+    noPermissionDeleteFiles: "У вас нет прав на удаление файлов",
+    linkExternal: "Подключить внешнюю папку",
+    linkExternalFolder: "Подключить внешнюю папку",
+    linkExternalFolderDescription: "Подключить каталог хоста (NAS, USB-накопитель или сетевой ресурс) к файловому менеджеру. Файлы не копируются — Bambuddy обращается к ним по исходному пути.",
+    externalFolderNamePlaceholder: "например, Модели на NAS",
+    externalPath: "Путь на хосте",
+    externalPathHelp: "Абсолютный путь к каталогу на хосте Docker. Каталог должен быть подключён к контейнеру через bind mount.",
+    readOnly: "Только чтение",
+    readOnlyHelp: "запрещает загрузку и удаление файлов",
+    showHiddenFiles: "Показывать скрытые файлы (dotfiles)",
+    externalFolder: "Внешняя папка",
+    scanFolder: "Сканировать",
+    toast: {
+      folderCreated: "Папка создана",
+      folderDeleted: "Папка удалена",
+      fileDeleted: "Файл удалён",
+      filesDeleted: "Удалено файлов: {{count}}",
+      filesMoved: "Файлы перемещены",
+      folderLinked: "Папка связана",
+      folderUnlinked: "Связь с папкой удалена",
+      externalFolderLinked: "Внешняя папка подключена и просканирована",
+      folderScanned: "Сканирование завершено: добавлено — {{added}}, удалено — {{removed}}",
+      addedToQueue: "Добавлено в очередь файлов: {{count}}",
+      addedToQueuePartial: "Добавлено файлов: {{added}}, ошибок: {{failed}}",
+      failedToAddToQueue: "Не удалось добавить файлы: {{error}}",
+      fileRenamed: "Файл переименован",
+      folderRenamed: "Папка переименована",
+      thumbnailsGenerated: "Создано миниатюр: {{count}}",
+      thumbnailsGeneratedPartial: "Создано миниатюр: {{succeeded}}, ошибок: {{failed}}",
+      noStlMissingThumbnails: "Нет STL-файлов без миниатюр",
+      failedToGenerateThumbnails: "Не удалось создать миниатюры: {{error}}",
+      thumbnailGenerated: "Миниатюра создана",
+      failedToGenerateThumbnail: "Не удалось создать миниатюру: {{error}}",
+    },
+  },
+  projects: {
+    title: "Проекты",
+    subtitle: "Организация и отслеживание проектов 3D-печати",
+    newProject: "Новый проект",
+    editProject: "Изменить проект",
+    deleteProject: "Удалить проект",
+    projectName: "Название проекта",
+    description: "Описание",
+    noProjects: "Проектов пока нет",
+    noProjectsFiltered: "Нет проектов со статусом «{{status}}»",
+    noProjectsFilteredHelp: "У вас нет проектов со статусом «{{status}}». Они появятся здесь после изменения статуса.",
+    createFirst: "Создайте первый проект, чтобы объединять связанные печати, отслеживать ход работ и управлять сборкой.",
+    createFirstButton: "Создать первый проект",
+    create: "Создать",
+    files: "Файлы",
+    prints: "Печати",
+    plates: "пластин",
+    parts: "деталей",
+    lastModified: "Последнее изменение",
+    deleteConfirm: "Удалить этот проект? Архивные записи и элементы очереди будут отвязаны, но не удалены.",
+    addFiles: "Добавить файлы",
+    removeFile: "Убрать файл",
+    viewDetails: "Подробнее",
+    namePlaceholder: "например, Сборка Voron 2.4",
+    descriptionPlaceholder: "Необязательное описание...",
+    urlLabel: "URL",
+    urlPlaceholder: "https://makerworld.com/...",
+    urlInvalid: "URL должен начинаться с http:// или https://",
+    openExternalUrl: "Открыть страницу проекта",
+    coverImageLabel: "Обложка",
+    coverImageAlt: "Обложка проекта",
+    coverImageUpload: "Загрузить",
+    coverImageReplace: "Заменить",
+    coverImageRemove: "Удалить",
+    color: "Цвет",
+    targetPlates: "План по пластинам",
+    targetPlatesPlaceholder: "например, 25",
+    targetPlatesHelp: "Количество заданий печати",
+    targetParts: "План по деталям",
+    targetPartsPlaceholder: "например, 150",
+    targetPartsHelp: "Общее требуемое количество объектов",
+    tagsLabel: "Метки (через запятую)",
+    tagsPlaceholder: "например, voron, функциональное, подарок",
+    dueDate: "Срок",
+    priority: "Приоритет",
+    priorityLow: "Низкий",
+    priorityNormal: "Обычный",
+    priorityHigh: "Высокий",
+    priorityUrgent: "Срочный",
+    statusActive: "Активный",
+    statusCompleted: "Завершён",
+    statusArchived: "В архиве",
+    done: "Готово",
+    completed: "завершено",
+    failed: "ошибка",
+    inQueue: "в очереди",
+    noPrintsYet: "Печатей пока нет",
+    printJobs: "Задания печати (пластины)",
+    partsPrinted: "Напечатано деталей",
+    failedParts: "Неудачных деталей",
+    import: "Импорт",
+    export: "Экспорт",
+    importProject: "Импортировать проект",
+    exportAll: "Экспортировать все проекты",
+    loading: "Загрузка проектов...",
+    noEditPermission: "У вас нет прав на изменение проектов",
+    noDeletePermission: "У вас нет прав на удаление проектов",
+    noCreatePermission: "У вас нет прав на создание проектов",
+    noImportPermission: "У вас нет прав на импорт проектов",
+    noExportPermission: "У вас нет прав на экспорт проектов",
+    toast: {
+      created: "Проект создан",
+      updated: "Проект обновлён",
+      deleted: "Проект удалён",
+      imported: "Проект импортирован",
+      multipleImported: "Импортировано проектов: {{count}}",
+      importFailed: "Не удалось импортировать проект",
+      exported: "Проекты экспортированы (только метаданные)",
+    },
+  },
+  projectDetail: {
+    notFound: "Проект не найден",
+    backToProjects: "Вернуться к проектам",
+    export: "Экспорт",
+    exportProject: "Экспортировать проект",
+    noExportPermission: "У вас нет прав на экспорт проектов",
+    noEditPermission: "У вас нет прав на изменение проектов",
+    partOf: "Входит в:",
+    priorityLabel: "Приоритет:",
+    noPrints: "В этом проекте пока нет печатей",
+    status: {
+      active: "Активный",
+      completed: "Завершён",
+      archived: "В архиве",
+    },
+    priority: {
+      low: "Низкий",
+      normal: "Обычный",
+      high: "Высокий",
+      urgent: "Срочный",
+    },
+    dueDate: {
+      overdue: "Срок истёк",
+      today: "Срок сегодня",
+      daysLeft: "Осталось дней: {{count}}",
+    },
+    progress: {
+      platesProgress: "Выполнение по пластинам",
+      partsProgress: "Выполнение по деталям",
+      printJobs: "заданий печати",
+      parts: "деталей",
+      percentComplete: "Выполнено {{percent}}%",
+      remaining: "Осталось: {{count}}",
+    },
+    stats: {
+      printJobs: "Задания печати",
+      total: "всего",
+      failed: "Неудачных: {{count}}",
+      partsPrinted: "Напечатано деталей: {{count}}",
+      printTime: "Время печати",
+      filamentUsed: "Израсходовано филамента",
+    },
+    cost: {
+      title: "Учёт стоимости",
+      filamentCost: "Стоимость филамента",
+      energy: "Электроэнергия",
+      totalCost: "Общая стоимость",
+      total: "Итого",
+      includesBom: "включая спецификацию",
+      budget: "Бюджет",
+      remaining: "Остаток",
+    },
+    subProjects: {
+      title: "Подпроекты ({{count}})",
+    },
+    notes: {
+      title: "Заметки",
+      noEditPermission: "У вас нет прав на изменение заметок",
+      placeholder: "Добавьте заметки о проекте...",
+      empty: "Заметок пока нет. Нажмите «Изменить», чтобы добавить.",
+    },
+    files: {
+      title: "Файлы",
+      linkFolders: "Свяжите папки из файлового менеджера",
+      forQuickAccess: "с этим проектом для быстрого доступа.",
+      fileCount: "Файлов: {{count}}",
+      empty: "Связанных папок нет. Откройте файловый менеджер и свяжите папку с проектом.",
+      noFiles: "В этой папке нет файлов.",
+    },
+    bom: {
+      title: "Спецификация",
+      acquired: "Приобретено: {{completed}}/{{total}}",
+      showAll: "Показать всё",
+      hideDone: "Скрыть готовое",
+      addPart: "Добавить позицию",
+      noAddPermission: "У вас нет прав на добавление позиций",
+      partNamePlaceholder: "Название позиции (например, винты M3×8)",
+      partName: "Название позиции",
+      qty: "Кол-во",
+      price: "Цена ({{currency}})",
+      sourcingUrlPlaceholder: "Ссылка на поставщика (необязательно)",
+      remarksPlaceholder: "Примечание (необязательно)",
+      deletePart: "Удалить позицию",
+      deleteConfirm: "Удалить «{{name}}»?",
+      noUpdatePermission: "У вас нет прав на обновление позиций",
+      noEditPermission: "У вас нет прав на изменение позиций",
+      noDeletePermission: "У вас нет прав на удаление позиций",
+      totalCost: "Общая стоимость:",
+      empty: "В спецификации пока нет позиций. Добавьте крепёж, электронику и другие компоненты, закупку которых нужно отслеживать.",
+    },
+    timeline: {
+      title: "Хронология активности",
+      empty: "Активности пока нет.",
+    },
+    template: {
+      saveAsTemplate: "Сохранить как шаблон",
+      noCreatePermission: "У вас нет прав на создание шаблонов",
+    },
+    queue: {
+      title: "Очередь",
+      viewAll: "Показать всё",
+      printing: "Печатается: {{count}}",
+      queued: "В очереди: {{count}}",
+    },
+    prints: {
+      title: "Печати ({{count}})",
+    },
+    toast: {
+      projectUpdated: "Проект обновлён",
+      partAdded: "Позиция добавлена",
+      partRemoved: "Позиция удалена",
+      exportFailed: "Не удалось экспортировать",
+      projectExported: "Проект экспортирован",
+      templateCreated: "Шаблон создан",
+    },
+  },
+  system: {
+    title: "Информация о системе",
+    version: "Версия",
+    uptime: "Время работы",
+    cpuUsage: "Загрузка процессора",
+    memoryUsage: "Использование памяти",
+    diskUsage: "Использование диска",
+    networkInfo: "Сетевая информация",
+    logs: "Журналы",
+    debugMode: "Режим отладки",
+    enableDebug: "Включить журналирование отладки",
+    disableDebug: "Отключить журналирование отладки",
+    downloadLogs: "Скачать журналы",
+    clearLogs: "Очистить журналы",
+    dockerInfo: "Информация о Docker",
+    containerName: "Имя контейнера",
+    imageName: "Имя образа",
+    platform: "Платформа",
+    architecture: "Архитектура",
+  },
+  sponsors: {
+    sectionTitle: "Независимый проект на поддержке сообщества",
+    tagline: "Bambuddy бесплатен и остаётся таким благодаря добровольной поддержке пользователей. Без венчурного капитала и привязки к облаку.",
+    viewSupporters: "Посмотреть сторонников проекта",
+    toastPrints: "С Bambuddy выполнено печатей: {{count}}. Проект остаётся бесплатным благодаря поддержке сообщества.",
+    toastCost: "С помощью Bambuddy учтено филамента на сумму {{total}}. Посмотрите, кто помогает проекту оставаться независимым.",
+    toastArchives: "С Bambuddy архивировано печатей: {{count}}. Посмотрите, кто помогает проекту оставаться независимым.",
+    toastAnniversary: "Вы уже год с Bambuddy! Посмотрите, кто помогает проекту оставаться независимым.",
+    toastVersionUpdate: "Обновлено до версии {{version}}. Bambuddy остаётся бесплатным благодаря поддержке сообщества.",
+    toastBusiness: "Используете Bambuddy для {{count}} принтеров? Для команд доступен план поддержки с приоритетным исправлением ошибок, выставлением счетов и прямой связью с разработчиком.",
+    businessCta: "Bambuddy для бизнеса",
+    businessTitle: "Bambuddy для бизнеса",
+    businessTagline: "Вы используете {{count}} принтеров. Для команд и печатных ферм доступны приоритетная поддержка, коммерческая лицензия и выставление счетов.",
+  },
+  library: {
+    title: "Библиотека филаментов",
+    addFilament: "Добавить филамент",
+    editFilament: "Изменить филамент",
+    deleteFilament: "Удалить филамент",
+    vendor: "Производитель",
+    material: "Материал",
+    color: "Цвет",
+    kFactor: "Коэффициент K",
+    temperature: "Температура",
+    noFilaments: "В библиотеке нет филаментов",
+    deleteConfirm: "Удалить этот филамент?",
+    importFromPrinter: "Импортировать с принтера",
+    exportToFile: "Экспортировать в файл",
+    runWithPipeline: {
+      actionLabel: "Запустить через конвейер",
+      noPermission: "У вас нет прав на запуск конвейеров",
+      modalTitle: "Запуск через конвейер",
+      confirmTitle: "Подтверждение запуска",
+      confirmIntro: "Предварительная проверка обнаружила проблемы",
+      sourceHint: "Источник",
+      pipelineHint: "Конвейер",
+      targetHint: "Назначение",
+      pipelineListAria: "Доступные конвейеры",
+      runAnyway: "Всё равно запустить",
+      loading: "Загрузка…",
+      empty: "Сохранённых конвейеров пока нет. Откройте диалог нарезки и нажмите «Сохранить как конвейер».",
+      noTarget: "Целевой принтер не задан",
+      noTargetMessage: "Для этого конвейера не задан целевой принтер. Откройте его в настройках и выберите принтер.",
+      copies: "Копии",
+      copiesHint: "макс. {{n}}",
+      classTarget: "Любой {{model}}",
+      toast: {
+        started: "Конвейер запущен",
+        failed: "Не удалось запустить конвейер",
+      },
+      issue: {
+        printerNotSet: "Для этого конвейера не задан целевой принтер.",
+        printerNotFound: "Целевой принтер больше не существует.",
+        printerDisabled: "Целевой принтер отключён.",
+        printerOffline: "Целевой принтер не в сети.",
+        filamentType: "Слот филамента {{slot}}: ожидается {{expected}}, в AMS установлен {{actual}}",
+        filamentColor: "Слот филамента {{slot}}: цвет отличается (ожидается {{expected}}, в AMS установлен {{actual}})",
+        amsSlotMissing: "Слот AMS {{slot}} недоступен на этом принтере",
+        filamentUnverified: "Филамент в слоте {{slot}} выбран из облачного или стандартного профиля и не может быть проверен заранее.",
+        noClassMatches: "Ни один принтер в этой установке не соответствует целевому классу модели конвейера ({{expected}}).",
+        classNotSet: "В качестве цели конвейера выбран класс принтера, но модель не указана.",
+      },
+    },
+  },
+  slice: {
+    title: "Нарезка модели",
+    action: "Нарезать",
+    actionAll: "Нарезать все пластины ({{count}})",
+    actionAllTitle: "Нарезать все пластины в один многостольный результат и сохранить в одном архиве. Выбор филаментов охватывает все слоты, используемые проектом.",
+    allPlatesToggle: "Нарезать все пластины ({{count}})",
+    slicing: "Нарезка…",
+    printer: "Профиль принтера",
+    process: "Профиль процесса",
+    filament: "Профиль филамента",
+    filamentSlot: "Филамент {{index}} ({{type}})",
+    selectPreset: "— Выберите профиль —",
+    loadingPresets: "Загрузка профилей…",
+    analyzingPlateFilaments: "Анализ филаментов пластины…",
+    analyzingPlateFilamentsHint: "Выполняется предварительная нарезка, чтобы определить, какие слоты AMS используются на этой пластине. Результат кэшируется, поэтому повторное открытие будет мгновенным.",
+    previewToast: "Анализ {{name}} — {{elapsed}}",
+    previewWithProgress: "Анализ {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
+    notUsedByPlate: "— не используется на этой пластине",
+    noPresetsForSlot: "Нет доступных профилей",
+    otherPrinters: "Другие принтеры",
+    presetsLoadFailed: "Не удалось загрузить профили. Сначала импортируйте их в разделе «Настройки → Профили».",
+    refreshPresets: "Обновить",
+    refreshPresetsTitle: "Обновить профили — получить свежие облачные и встроенные списки. Используйте после удаления профиля в Bambu Studio или Bambu Handy.",
+    allPresetsRequired: "Необходимо выбрать все профили",
+    useEmbedded: "Использовать встроенные настройки файла",
+    useEmbeddedHint: "Нарезать так, как задумал автор модели (стенки, заполнение, филамент), вместо профилей выше. Доступно, потому что ваш принтер совпадает с указанным в файле.",
+    enqueuing: "Отправка задания на нарезку…",
+    queued: "В очереди…",
+    failed: "Ошибка нарезки. Проверьте журналы вспомогательного сервиса слайсера.",
+    startedToast: "Фоновая нарезка {{name}}…",
+    queuedToast: "В очереди: {{name}} — {{elapsed}}",
+    runningToast: "Нарезка {{name}} — {{elapsed}}",
+    runningWithProgress: "{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
+    runningWithProgressMultiPlate: "Пластина {{plateIndex}} из {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
+    completedToast: "Нарезка {{name}} завершена",
+    failedTitle: "Ошибка нарезки",
+    failedToast: "Не удалось нарезать {{name}}: {{detail}}",
+    tier: {
+      local: "Импортированные",
+      cloud: "Bambu Cloud",
+      orcaCloud: "Orca Cloud",
+      standard: "Стандартные",
+    },
+    cloud: {
+      notAuthenticated: "Войдите в Bambu Cloud («Настройки → Профили → Bambu Cloud»), чтобы увидеть облачные профили.",
+      expired: "Сессия Bambu Cloud истекла. Войдите снова, чтобы обновить облачные профили.",
+      unreachable: "Bambu Cloud сейчас недоступен. Локальные и стандартные профили продолжат работать.",
+    },
+    orcaCloud: {
+      notAuthenticated: "Войдите в Orca Cloud («Профили → Orca Cloud»), чтобы увидеть профили Orca.",
+      expired: "Сессия Orca Cloud истекла. Войдите снова, чтобы обновить профили Orca.",
+      unreachable: "Orca Cloud сейчас недоступен. Остальные профили продолжат работать.",
+    },
+    bedType: {
+      label: "Печатная пластина",
+      auto: "Авто (из профиля процесса)",
+      coolPlate: "Холодная пластина",
+      coolPlateSuperTack: "Холодная пластина SuperTack",
+      engineering: "Инженерная пластина",
+      highTemp: "Высокотемпературная пластина",
+      texturedPEI: "Текстурированная PEI-пластина",
+      smoothPEI: "Гладкая PEI-пластина",
+    },
+    pipelines: {
+      label: "Конвейер",
+      applyAria: "Применить конвейер",
+      applyPrompt: "Применить конвейер…",
+      empty: "Нет сохранённых конвейеров",
+      saveButton: "Сохранить как конвейер",
+      saveTitle: "Сохранить текущий набор из четырёх профилей как повторно используемый конвейер",
+      namePlaceholder: "Название конвейера",
+      nameAria: "Название нового конвейера",
+      toast: {
+        applied: "Применён конвейер «{{name}}»",
+        saved: "Конвейер сохранён",
+        saveFailed: "Не удалось сохранить",
+      },
+    },
+  },
+  spoolman: {
+    title: "Интеграция со Spoolman",
+    enabled: "Spoolman включён",
+    url: "URL Spoolman",
+    connected: "Подключено",
+    disconnected: "Нет подключения",
+    testConnection: "Проверить подключение",
+    sync: "Синхронизировать",
+    syncing: "Синхронизация…",
+    lastSync: "Последняя синхронизация",
+    linkToSpoolman: "Связать со Spoolman",
+    openInSpoolman: "Открыть в Spoolman",
+    unlinkSpool: "Отвязать катушку",
+    unlinkConfirmTitle: "Снять назначение катушки?",
+    unlinkConfirmMessage: "Катушка будет удалена из этого слота. Данные самой катушки останутся без изменений.",
+    selectSpool: "Выбрать катушку",
+    noUnlinkedSpools: "Нет свободных катушек",
+    linkSuccess: "Катушка успешно назначена",
+    linkFailed: "Не удалось назначить катушку",
+    unlinkSuccess: "Назначение катушки снято",
+    unlinkFailed: "Не удалось снять назначение катушки",
+    linkedSpool: "Назначенная катушка",
+    spoolId: "ID катушки",
+    fillSourceLabel: "(Spoolman)",
+    weight: "Масса",
+    remaining: "Остаток",
+    disableWeightSync: "Отключить синхронизацию расчётной массы AMS",
+    disableWeightSyncDesc: "Не обновлять остаток по оценке AMS. Используйте это, если предпочитаете учёт расхода Spoolman процентной оценке AMS. Для новых катушек оценка AMS всё равно будет использована как начальная масса.",
+    reportPartialUsage: "Учитывать частичный расход при неудачной печати",
+    reportPartialUsageDesc: "Если печать завершилась ошибкой или была отменена, учитывать примерный расход филамента до этого момента по прогрессу слоёв.",
+  },
+  locations: {
+    title: "Места хранения",
+    subtitle: "Управление полками, ящиками и другими физическими местами хранения катушек",
+    add: "Добавить место",
+    addShort: "Добавить",
+    edit: "Изменить место",
+    name: "Название",
+    spools: "Катушки",
+    empty: "Мест хранения пока нет. Создайте первую полку или ящик.",
+    manage: "Места хранения",
+    createPlaceholder: "например, Полка A или Ящик 1",
+    nameRequired: "Укажите название места хранения",
+    created: "Место хранения создано",
+    updated: "Место хранения обновлено",
+    deleted: "Место хранения удалено",
+    saveFailed: "Не удалось сохранить место хранения",
+    deleteFailed: "Не удалось удалить место хранения",
+    deleteBlocked: "Перед удалением переместите из этого места все катушки",
+    confirmDelete: "Удалить «{{name}}»?",
+    confirmDeleteMessage: "Это место будет удалено из каталога. Сначала необходимо переместить все катушки.",
+  },
+  inventory: {
+    title: "Учёт катушек",
+    subtitle: "Управление катушками",
+    addToInventory: "Добавить в учёт",
+    addToInventoryPending: "Добавление…",
+    addToInventorySuccess: "Катушка добавлена в учёт",
+    addToInventoryFailed: "Не удалось добавить катушку в учёт",
+    unknownSpoolTitle: "Обнаружен новый филамент",
+    unknownSpoolMessage: "В {{location}} обнаружена катушка с неизвестной RFID-меткой. Добавить её в учёт?",
+    unknownSpoolSlot: "Слот",
+    bulk: {
+      selectAllVisible: "Выбрать все видимые",
+      selectRow: "Выбрать строку",
+      selectGroup: "Выбрать группу",
+      selectionCount: "Выбрано: {{count}}",
+      edit: "Изменить",
+      printLabels: "Печать этикеток",
+      resetUsage: "Сбросить расход",
+      restore: "Восстановить",
+      archive: "Архивировать",
+      delete: "Удалить",
+      clearSelection: "Снять выделение",
+      editTitle: "Массовое изменение катушек",
+      editSubtitle: "Изменения применятся к {{count}} выбранным катушкам. Обновятся только отмеченные поля.",
+      editHint: "Введите значение в поле, чтобы включить его обновление. Будут отправлены только отмеченные строки. Пустое поле не изменяет катушки; очистить значение можно только при редактировании отдельной катушки.",
+      useCustom: "Использовать «{{value}}»",
+      toggleField: "Включить или отключить обновление этого поля",
+      changeCount: "Будет обновлено полей: {{count}}.",
+      applyPending: "Применение…",
+      applyButton: "Применить к {{count}} катушкам",
+      deleteTitle: "Удалить выбранные катушки",
+      archiveTitle: "Архивировать выбранные катушки",
+      restoreTitle: "Восстановить выбранные катушки",
+      resetUsageTitle: "Сбросить расход выбранных катушек",
+      deleteMessage: "Безвозвратно удалить {{count}} катушек? Это действие нельзя отменить.",
+      archiveMessage: "Архивировать {{count}} катушек? Позже их можно будет восстановить.",
+      restoreMessage: "Восстановить {{count}} архивных катушек?",
+      resetUsageMessage: "Сбросить счётчик «Израсходовано всего» у {{count}} катушек? Текущий остаток массы сохранится.",
+      updateSuccess: "Обновлено катушек: {{count}}",
+      updateFailed: "Ошибка массового обновления",
+      updatePartial: "Обновлено: {{ok}}, с ошибкой: {{failed}}",
+      updateAllFailed: "Не удалось обновить все {{count}} катушек. Выделение сохранено для повторной попытки.",
+      deleteSuccess: "Удалено катушек: {{count}}",
+      deleteFailed: "Ошибка массового удаления",
+      deletePartial: "Удалено: {{ok}}, с ошибкой: {{failed}}",
+      deleteAllFailed: "Не удалось удалить все {{count}} катушек. Выделение сохранено для повторной попытки.",
+      archiveSuccess: "Архивировано катушек: {{count}}",
+      archiveFailed: "Ошибка массового архивирования",
+      archivePartial: "Архивировано: {{ok}}, с ошибкой: {{failed}}",
+      archiveAllFailed: "Не удалось архивировать все {{count}} катушек. Выделение сохранено для повторной попытки.",
+      restoreSuccess: "Восстановлено катушек: {{count}}",
+      restoreFailed: "Ошибка массового восстановления",
+      restorePartial: "Восстановлено: {{ok}}, с ошибкой: {{failed}}",
+      restoreAllFailed: "Не удалось восстановить все {{count}} катушек. Выделение сохранено для повторной попытки.",
+      invalidHex: "Введите 6 шестнадцатеричных символов (RRGGBB) или 8 (RRGGBBAA), иначе поле не будет применено.",
+    },
+    spoolmanMixedContentTitle: "Spoolman не загружается через HTTPS: браузер заблокировал смешанное содержимое",
+    spoolmanMixedContentBody: "Bambuddy открыт через HTTPS (через обратный прокси), а URL Spoolman всё ещё использует обычный HTTP. Браузер блокирует такое смешанное содержимое, поэтому встроенный интерфейс Spoolman не отображается. Для работы Spoolman должен быть доступен по HTTPS.",
+    spoolmanMixedContentFixReverseProxy: "Разместите Spoolman за тем же обратным прокси, что и Bambuddy (Traefik / Nginx / Caddy), включите HTTPS и укажите новый HTTPS-адрес Spoolman в настройках.",
+    spoolmanMixedContentFixOpenNewTab: "В качестве обходного решения откройте Spoolman по HTTP в новой вкладке. Ограничение смешанного содержимого действует только для встроенных фреймов.",
+    spoolmanOpenInNewTab: "Открыть Spoolman в новой вкладке",
+    labels: {
+      title: "Печать этикеток катушек",
+      selectedCount: "Выбрано: {{count}}",
+      pickSpools: "Выберите катушки для печати этикеток:",
+      monochrome: "Монохромный режим (чёрно-белый принтер)",
+      monochromeHint: "Убирает образец цвета и расширяет область текста",
+      searchPlaceholder: "Поиск по названию, бренду или #ID",
+      filterByMaterial: "Материал:",
+      allMaterials: "Все",
+      selectVisible: "Выбрать все видимые ({{count}})",
+      deselectVisible: "Снять выделение с видимых",
+      clearAll: "Очистить выбор",
+      noSpoolsToShow: "Нет катушек для отображения. Измените фильтр и повторите попытку.",
+      noMatches: "Нет катушек, соответствующих поиску или фильтру.",
+      printOne: "Напечатать этикетку этой катушки",
+      printLabels: "Печать этикеток…",
+      bulkTitle: "Выберите катушки для печати этикеток из {{count}} отображаемых",
+      noSpoolsTitle: "Нет катушек для печати этикеток",
+      error: "Не удалось создать этикетки: {{msg}}",
+      sortBy: {
+        label: "Сортировка:",
+        id: "По ID",
+        color: "По цвету",
+      },
+      templates: {
+        amsHolderSmall: {
+          label: "Держатель AMS — малый (74 × 33 мм)",
+          hint: "Одна этикетка на страницу; соответствует печатной этикетке для модели MakerWorld 752566 (AMS Filament Label Holder).",
+        },
+        amsHolderLarge: {
+          label: "Держатель AMS — большой (75 × 55 мм)",
+          hint: "Одна этикетка на страницу; подходит для варианта держателя AMS с картонной вставкой. Достаточно места для образца цвета, бренда, материала, ID и QR-кода.",
+        },
+        box40x30: {
+          label: "Этикетка для коробки (40 × 30 мм)",
+          hint: "Одна этикетка на страницу; распространённый размер рулона DK/Brother, подходит для пакетов с филаментом и контейнеров хранения.",
+        },
+        box: {
+          label: "Этикетка для коробки (62 × 29 мм)",
+          hint: "Одна этикетка на страницу; размер для малых этикеток Brother PT/QL и Dymo.",
+        },
+        averyL7160: {
+          label: "Avery L7160 — лист A4 (38,1 × 63,5 мм × 21)",
+          hint: "Европейский формат: 21 этикетка на листе A4.",
+        },
+        avery5160: {
+          label: "Avery 5160 — лист US Letter (25,4 × 66,7 мм × 30)",
+          hint: "Американский формат: 30 этикеток на листе Letter.",
+        },
+      },
+    },
+    csv: {
+      importButton: "Импорт CSV",
+      exportButton: "Экспорт CSV",
+      modalTitle: "Импорт катушек из CSV",
+      selectFile: "Выберите CSV-файл или перетащите его сюда",
+      dragHint: "Заголовок: material (обязательно), brand, subtype, color_name, rgba, …",
+      parsing: "Чтение файла…",
+      previewError: "Не удалось прочитать CSV-файл",
+      validCount: "Корректных: {{count}}",
+      errorCount: "Ошибок: {{count}}",
+      skippedCount: "Пропущено: {{count}}",
+      colRow: "Строка",
+      colStatus: "Состояние",
+      colColor: "Цвет",
+      colorResolved: "Цвет подставлен из каталога",
+      colorCrossMaterial: "Цвет взят у другого материала: точного совпадения в каталоге нет",
+      duplicateExisting: "Катушка с таким материалом, брендом и цветом уже существует, но запись всё равно будет импортирована как новая катушка",
+      spoolmanHint: "В режиме Spoolman используйте встроенный импорт и экспорт CSV в Spoolman.",
+      importValidRows: "Импортировать {{count}} корректных строк",
+      noValidRows: "Нет корректных строк",
+      importing: "Импорт…",
+      importSuccess: "Импортировано катушек: {{count}}",
+      importError: "Ошибка импорта",
+      exportError: "Ошибка экспорта",
+    },
+    addSpool: "Добавить катушку",
+    editSpool: "Изменить катушку",
+    copySpool: "Копировать катушку",
+    material: "Материал",
+    selectMaterial: "Выберите материал…",
+    subtype: "Подтип",
+    brand: "Бренд",
+    searchBrand: "Поиск бренда…",
+    useCustomBrand: "Использовать «{{brand}}»",
+    useCustomMaterial: "Использовать свой материал: {{material}}",
+    colorName: "Название цвета",
+    colorNamePlaceholder: "Jade White, Fire Red…",
+    color: "Цвет",
+    hexColor: "Цвет HEX",
+    pickColor: "Выбрать свой цвет",
+    labelWeight: "Номинальная масса",
+    coreWeight: "Масса пустой катушки",
+    searchSpoolWeight: "Поиск массы катушки…",
+    weightUsed: "Израсходовано",
+    currentWeight: "Остаток массы",
+    measuredWeight: "Измеренная масса",
+    spoolName: "Катушка",
+    costPerKg: "Стоимость за кг",
+    storageLocation: "Место хранения",
+    storageLocationPlaceholder: "например, Полка A, Ящик 1",
+    openInInventory: "Открыть в учёте",
+    measuredWeightError: "Измеренная масса должна быть от {{min}} до {{max}} г.",
+    slicerFilament: "Филамент слайсера",
+    slicerFilamentName: "Название пресета слайсера",
+    slicerPreset: "Пресет слайсера",
+    searchPresets: "Поиск пресетов филамента…",
+    selectedPreset: "Выбрано",
+    noPresetsFound: "Пресеты не найдены",
+    tempOverrides: "Переопределение температур",
+    note: "Примечание",
+    notePlaceholder: "Дополнительные сведения об этой катушке…",
+    category: "Категория",
+    categoryPlaceholder: "например, Производство, Прототип, Клиент A",
+    categoryNone: "Без категории",
+    storageLocationNone: "Место не указано",
+    lowStockThresholdOverride: "Порог малого остатка (эта катушка)",
+    lowStockThresholdOverrideHelp: "Оставьте пустым, чтобы использовать общий порог ({{global}}%).",
+    clearRfid: "Очистить RFID-метку",
+    rfidCleared: "RFID-метка очищена",
+    archive: "Архивировать",
+    restore: "Восстановить",
+    noSpools: "Катушек пока нет. Добавьте первую катушку.",
+    noAvailableSpools: "Нет доступных катушек. Добавьте катушку в учёт или сначала освободите её в другом слоте.",
+    kProfiles: "K-профили",
+    addKProfile: "Добавить K-профиль",
+    assignSpool: "Назначить катушку",
+    unassignSpool: "Снять назначение",
+    assignSuccess: "Катушка назначена, слот AMS настроен",
+    assignPendingInsert: "Катушка назначена. Слот будет настроен после её установки.",
+    assignFailed: "Не удалось назначить катушку",
+    selectSpool: "Выберите катушку для назначения этому слоту",
+    assigned: "Назначено",
+    assigning: "Назначение…",
+    searchSpools: "Поиск катушек…",
+    showAllSpools: "Показать все катушки",
+    spoolmanSpools: "Катушки Spoolman",
+    allMaterials: "Все материалы",
+    filterByBrand: "Фильтр по бренду…",
+    showArchived: "Показывать архивные",
+    quickAdd: "Быстрое добавление",
+    quantity: "Количество",
+    stock: "На складе",
+    configured: "Настроено",
+    spoolsCreated: "Создано катушек: {{count}}",
+    spoolsPartiallyCreated: "Создано {{created}} из {{total}} катушек; часть операций завершилась ошибкой",
+    spoolCreated: "Катушка создана",
+    spoolUpdated: "Катушка обновлена",
+    spoolDeleted: "Катушка удалена",
+    deepLinkSpoolNotFound: "Катушка не найдена",
+    deepLinkFetchFailed: "Не удалось загрузить катушку. Повторите попытку.",
+    spoolArchived: "Катушка архивирована",
+    spoolRestored: "Катушка восстановлена",
+    kProfileSaveFailed: "Не удалось сохранить настройки K-профиля",
+    syncWeightSpoolNotFound: "Катушка не найдена: возможно, она была удалена",
+    syncWeightSpoolmanUnreachable: "Spoolman недоступен. Повторите попытку позже.",
+    syncWeightFailed: "Не удалось синхронизировать массу",
+    spoolmanUnreachable: "Spoolman недоступен. Повторите попытку позже.",
+    deleteSpoolNotFound: "Катушка не найдена: возможно, она уже удалена",
+    deleteFailed: "Не удалось удалить катушку",
+    archiveSpoolNotFound: "Катушка не найдена: возможно, она уже удалена",
+    archiveFailed: "Не удалось архивировать катушку",
+    restoreSpoolNotFound: "Катушка не найдена: возможно, она уже удалена",
+    restoreFailed: "Не удалось восстановить катушку",
+    saveFailed: "Не удалось сохранить изменения",
+    tagClearFailed: "Не удалось очистить метку",
+    deleteConfirm: "Удалить эту катушку без возможности восстановления?",
+    archiveConfirm: "Архивировать эту катушку?",
+    advancedSettings: "Расширенные настройки",
+    filamentInfoTab: "Сведения о филаменте",
+    paProfileTab: "Профиль PA",
+    filamentInfo: "Филамент",
+    additional: "Дополнительно",
+    loadingPresets: "Загрузка облачных пресетов…",
+    cloudConnected: "Облако подключено",
+    cloudNotConnected: "Облако не подключено; используются значения по умолчанию",
+    recentColors: "Недавние",
+    searchColors: "Поиск цветов…",
+    searchResults: "Результаты поиска",
+    allColors: "Все цвета",
+    commonColors: "Основные цвета",
+    showLess: "Показать меньше",
+    showAll: "Показать все",
+    noColorsFound: "Нет цветов, соответствующих запросу",
+    noResults: "Совпадений не найдено",
+    extraColorsLabel: "Дополнительные цвета",
+    extraColorsPlaceholder: "EC984C,#6CD4BC,A66EB9,D87694",
+    extraColorsHint: "Вставьте от 2 до 8 HEX-цветов через запятую. Они будут отображены как градиент.",
+    extraColorsInvalid: "Пропущены некорректные HEX-значения: {{tokens}}",
+    colorEffectLabel: "Эффект",
+    colorEffect: {
+      none: "Нет",
+      sparkle: "Блёстки",
+      wood: "Дерево",
+      marble: "Мрамор",
+      glow: "Свечение",
+      matte: "Матовый",
+      silk: "Шёлк",
+      galaxy: "Галактика",
+      rainbow: "Радуга",
+      metal: "Металл",
+      translucent: "Полупрозрачный",
+      gradient: "Градиент",
+      dualColor: "Двухцветный",
+      triColor: "Трёхцветный",
+      multicolor: "Многоцветный",
+    },
+    selectMaterialFirst: "Сначала выберите материал на вкладке «Сведения о филаменте».",
+    noPrintersConfigured: "Принтеры не настроены. Добавьте принтеры, чтобы использовать профили PA.",
+    matchingFilter: "Совпадающие",
+    anyBrand: "Любой бренд",
+    anyVariant: "Любой вариант",
+    autoSelect: "Автовыбор",
+    matches: "совпадений",
+    match: "совпадение",
+    noMatches: "Нет совпадений",
+    connected: "Подключён",
+    offline: "Не в сети",
+    printerOffline: "Принтер не в сети. Подключите его для просмотра калибровочных профилей.",
+    noKProfilesMatch: "Нет K-профилей, соответствующих выбранному филаменту.",
+    leftNozzle: "Левое сопло",
+    rightNozzle: "Правое сопло",
+    profilesSelected: "выбрано калибровочных профилей",
+    totalInventory: "Всего в учёте",
+    totalConsumed: "Израсходовано всего",
+    byMaterial: "По материалам",
+    inPrinter: "В принтерах",
+    lowStock: "Малый остаток",
+    sinceTracking: "С начала учёта",
+    resetConsumedCounter: "Сбросить счётчик",
+    resetConsumedCounterTooltip: "Обнулить счётчик израсходованных граммов для этой катушки. Остаток массы не изменится.",
+    resetConsumedCounterConfirm: "Сбросить счётчик расхода этой катушки до 0? Последующие печати будут учитываться заново. Сама катушка, расчёт остатка и настройки не изменятся.",
+    resetAllConsumedCounters: "Сбросить все счётчики",
+    resetAllConsumedCountersTooltip: "Обнулить счётчик израсходованных граммов у всех катушек. Остатки массы не изменятся.",
+    resetAllConsumedCountersConfirm: "Сбросить счётчик расхода до 0 у всех {{count}} катушек, включая архивные? Показатель «Израсходовано всего» будет очищен, а будущие печати начнут учитываться с нуля. Катушки и их остатки не изменятся.",
+    consumedCounterReset: "Счётчик сброшен",
+    allConsumedCountersReset: "Счётчик сброшен у {{count}} катушек",
+    resetConsumedCounterFailed: "Не удалось сбросить счётчик",
+    loadedInAms: "Загружено в AMS/внешний слот",
+    remaining: "Остаток",
+    weightCheck: "Проверка массы",
+    lastWeighed: "Последнее взвешивание",
+    neverWeighed: "Не взвешивалась",
+    search: "Поиск катушек…",
+    showing: "Показано",
+    to: "–",
+    of: "из",
+    show: "Показывать",
+    spools: "катушек",
+    spool: "катушка",
+    page: "Страница",
+    noSpoolsMatch: "Ничего не найдено",
+    noSpoolsMatchDesc: "Измените поисковый запрос или фильтры.",
+    active: "Активные",
+    archived: "Архивные",
+    all: "Все",
+    used: "Израсходовано",
+    new: "Новые",
+    clearFilters: "Сбросить фильтры",
+    table: "Таблица",
+    cards: "Карточки",
+    net: "Нетто",
+    groupSimilar: "Группировать",
+    groupedSpools: "Одинаковых катушек: {{count}}",
+    groupedRows: "строк",
+    columns: "Столбцы",
+    configureColumns: "Настроить столбцы",
+    configureColumnsDesc: "Перетаскивайте столбцы или используйте стрелки для изменения порядка. Видимость переключается значком глаза.",
+    visible: "видимых",
+    reset: "Сбросить",
+    cancel: "Отмена",
+    applyChanges: "Применить изменения",
+    moveUp: "Переместить вверх",
+    moveDown: "Переместить вниз",
+    hideColumn: "Скрыть столбец",
+    showColumn: "Показать столбец",
+    linkToSpool: "Связать с катушкой",
+    tagLinked: "Метка связана с катушкой",
+    tagLinkFailed: "Не удалось связать метку",
+    tagAlreadyLinked: "Метка уже связана с другой катушкой",
+    unknownTag: "Обнаружена неизвестная RFID-метка",
+    usageHistory: "История расхода",
+    noUsageHistory: "Расход пока не зарегистрирован",
+    printName: "Название печати",
+    weightConsumed: "Израсходованная масса",
+    clearHistory: "Очистить",
+    historyCleared: "История расхода очищена",
+    fillSourceLabel: "(Учёт)",
+    lowStockThresholdError: "Порог должен быть от 0,1 до 99,9",
+    assignMismatchTitle: "Несоответствие материала",
+    assignMismatchMessage: "Материал выбранной катушки «{{spoolMaterial}}» не соответствует материалу слота «{{trayMaterial}}» в {{location}}. Всё равно назначить?",
+    assignMismatchConfirm: "Всё равно назначить",
+    assignPartialMismatchMessage: "Материал катушки «{{spoolMaterial}}» похож, но не совпадает точно с «{{trayMaterial}}» в {{location}}. Продолжить?",
+    assignProfileMismatchMessage: "Профиль катушки «{{spoolProfile}}» не соответствует профилю слота «{{trayProfile}}» в {{location}}. Продолжить?",
+    assignReconfigureNote: "Слот AMS будет перенастроен на профиль катушки.",
+    spoolmanFilamentCatalog: "Каталог филаментов Spoolman",
+    pickFromSpoolmanCatalog: "Выбрать из каталога Spoolman…",
+    spoolmanFilamentSelected: "Филамент выбран из каталога Spoolman",
+    spoolmanFilamentUnlinked: "Связь с каталогом филаментов удалена",
+    noSpoolmanFilaments: "В каталоге Spoolman филаменты не найдены",
+    spoolmanFilamentColorSwatch: "Цвет филамента",
+    spoolWeightManagedBySpoolman: "Масса пустой катушки задаётся в Spoolman для каждого типа филамента",
+    spoolmanCatalogLoadFailed: "Не удалось загрузить каталог филаментов Spoolman",
+  },
+  timelapse: {
+    title: "Таймлапс",
+    create: "Создать таймлапс",
+    download: "Скачать",
+    delete: "Удалить",
+    preview: "Предпросмотр",
+    frameRate: "Частота кадров",
+    quality: "Качество",
+    processing: "Обработка...",
+    noTimelapses: "Таймлапсы отсутствуют",
+  },
+  ams: {
+    title: "AMS",
+    slot: "Слот",
+    empty: "Пусто",
+    emptySlot: "Пустой слот",
+    slotEmpty: "Пусто",
+    slotUnconfigured: "?",
+    emptySlotReset: "Филамент не назначен",
+    unknown: "Неизвестно",
+    humidity: "Влажность",
+    temperature: "Температура",
+    filamentType: "Тип филамента",
+    filamentColor: "Цвет",
+    remaining: "Остаток",
+    history: "История AMS",
+    noHistory: "История отсутствует",
+    configureSlot: "Настроить слот",
+    externalSpool: "Внешняя катушка",
+    profile: "Профиль",
+    kFactor: "Коэффициент K",
+    fill: "Заполнить",
+    configure: "Настроить",
+    used: "использовано",
+    remainingUnit: "осталось",
+  },
+  printModal: {
+    selectPrinter: "Выберите принтер",
+    selectPlate: "Выберите пластину",
+    filamentMapping: "Сопоставление филаментов",
+    plateN: "Пластина {{n}}",
+    plateFilamentsUnreadable: "Не удалось определить филаменты выбранной пластины, поэтому их невозможно сопоставить. Снимите выбор с этой пластины, чтобы добавить остальные в очередь.",
+    totalCost: "Общая стоимость:",
+    slotRemainingShort: " — осталось {{grams}} г",
+    printSettings: "Настройки печати",
+    bedLeveling: "Калибровка стола",
+    flowCalibration: "Калибровка потока",
+    vibrationCalibration: "Калибровка вибраций",
+    layerInspection: "Проверка первого слоя",
+    timelapse: "Таймлапс",
+    cancel: "Отмена",
+    noPrintersAvailable: "Нет доступных принтеров",
+    printerBusy: "Принтер занят",
+    printerOffline: "Принтер не в сети",
+    sameTypeDifferentColor: "Тот же тип, другой цвет",
+    filamentTypeNotLoaded: "Филамент этого типа не загружен",
+    whenToPrint: "Когда печатать",
+    asap: "Как можно скорее",
+    queue: "В очередь",
+    schedule: "По расписанию",
+    dateTime: "Дата и время",
+    invalidDateTime: "Введите корректные дату и время",
+    openCalendar: "Открыть календарь",
+    requireManualStart: "Требовать ручного запуска",
+    requirePreviousSuccess: "Запускать только после успешной предыдущей печати",
+    autoOffAfter: "Выключить принтер после завершения",
+    helpAsap: "Задание будет добавлено в начало очереди и запустится, как только подходящий принтер освободится.",
+    helpSchedule: "Задание запустится в указанное время, если принтер свободен. Если принтер занят, оно будет ждать освобождения.",
+    helpQueue: "Задание будет добавлено в конец очереди.",
+    leftNozzle: "Л",
+    rightNozzle: "П",
+    leftNozzleTooltip: "Левое сопло",
+    rightNozzleTooltip: "Правое сопло",
+    filamentOverride: "Переопределение филаментов",
+    filamentOverrideHint: "При необходимости замените филаменты для назначения по модели. Планировщик будет сопоставлять выбранные филаменты вместо исходных значений из 3MF.",
+    originalFilament: "Исходный",
+    overrideWith: "Заменить на",
+    resetToOriginal: "Вернуть исходный",
+    insufficientFilamentTitle: "Недостаточно филамента",
+    waitingForAmsStatus: "Ожидание состояния AMS от принтера {{printer}}…",
+    insufficientFilamentMessage: "На некоторых назначенных катушках осталось меньше филамента, чем требуется для этой печати:",
+    insufficientFilamentLine: "{{printer}} — {{slot}}: требуется {{required}} г, осталось {{remaining}} г",
+    printAnyway: "Всё равно печатать",
+    forceColorMatch: "Обязательно учитывать цвет",
+    staggerPrinterStarts: "Запускать принтеры с задержкой",
+    staggerGroupSize: "Размер группы",
+    staggerInterval: "Интервал (мин)",
+    staggerPreview: "{{printers}} принтеров → {{groups}} групп по {{size}}, запуск каждые {{interval}} мин",
+    staggerLastGroup: "последняя группа: {{count}}",
+    staggerTotal: "всего: {{minutes}} мин",
+    staggerToPrinters: "Распределить запуск для {{count}} принтеров",
+    gcodeInjection: "Добавить G-code автозапуска",
+  },
+  backup: {
+    includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
+    title: "Резервное копирование и восстановление",
+    createBackup: "Создать резервную копию",
+    restoreBackup: "Восстановить из копии",
+    restoreDescription: "Заменить все данные данными из резервной копии",
+    downloadBackup: "Скачать резервную копию",
+    uploadBackup: "Загрузить резервную копию",
+    lastBackup: "Последняя копия",
+    autoBackup: "Автоматическое резервное копирование",
+    backupNow: "Создать копию сейчас",
+    restoreWarning: "Внимание: восстановление из резервной копии перезапишет все текущие данные.",
+    includeArchives: "Включить архивы",
+    includeSettings: "Включить настройки",
+    includeProfiles: "Включить профили",
+    backupSuccess: "Резервная копия успешно создана",
+    restoreSuccess: "Резервная копия успешно восстановлена",
+    backupFailed: "Не удалось создать резервную копию",
+    restoreFailed: "Не удалось выполнить восстановление",
+    restoreNote: "На время восстановления виртуальный принтер будет остановлен",
+    githubBackup: "Резервная копия в Git",
+    enabled: "Включено",
+    cloudLoginRequired: "Требуется вход в Bambu Cloud. Войдите в разделе «Профили → Облачные профили», чтобы включить резервное копирование в GitHub.",
+    cloudLoginRequiredShort: "Требуется вход в облако",
+    githubDescription: "Автоматически синхронизировать профили с приватным репозиторием GitHub для резервного копирования и хранения истории версий.",
+    repoIsPrivate: "Репозиторий приватный — резервное копирование безопасно.",
+    repoIsPublicWarning: "Репозиторий ПУБЛИЧНЫЙ. Резервные копии Bambuddy содержат учётные данные MQTT, токены Home Assistant и Prometheus, адрес электронной почты Bambu Cloud и коды доступа к принтерам из K-профилей. Сохранение заблокировано, пока репозиторий не будет сделан приватным в настройках провайдера.",
+    repoVisibilityUnknown: "Не удалось определить видимость репозитория. Bambuddy отказывается создавать резервные копии в репозитории, приватность которого не подтверждена; сохранение будет заблокировано.",
+    repositoryUrl: "URL репозитория",
+    repoUrlPlaceholderGitHub: "https://github.com/username/repo-name",
+    repoUrlPlaceholderGitea: "https://gitea.example.com/username/repo-name",
+    repoUrlPlaceholderForgejo: "https://forgejo.example.com/username/repo-name",
+    repoUrlPlaceholderGitLab: "https://gitlab.com/username/repo-name",
+    allowInsecureHttp: "Разрешить небезопасный HTTP",
+    allowInsecureHttpHint: "Включайте для локальных серверов в частной сети без TLS",
+    personalAccessToken: "Персональный токен доступа",
+    tokenSaved: "(сохранён)",
+    enterNewToken: "Введите новый токен для обновления",
+    tokenHint: "Токен с точечными правами на чтение и запись содержимого",
+    branch: "Ветка",
+    provider: "Git-провайдер",
+    providerGitHub: "GitHub",
+    providerGitLab: "GitLab",
+    providerGitea: "Gitea",
+    providerForgejo: "Forgejo",
+    manualOnly: "Только вручную",
+    hourly: "Ежечасно",
+    daily: "Ежедневно",
+    weekly: "Еженедельно",
+    includeInBackup: "Включить в резервную копию",
+    kProfiles: "K-профили",
+    kProfilesDescription: "Калибровки Pressure Advance с подключённых принтеров",
+    noPrintersConnected: "Нет подключённых принтеров",
+    printersConnected: "Подключено {{connected}} из {{total}}",
+    cloudProfiles: "Облачные профили",
+    cloudProfilesDescription: "Предустановки филамента, принтера и процесса из Bambu Cloud",
+    appSettings: "Настройки приложения",
+    appSettingsDescription: "Конфигурация Bambuddy (полная база данных)",
+    spoolInventory: "Учёт катушек",
+    spoolInventoryDescription: "Катушки филамента, история расхода и учёт стоимости",
+    printArchives: "Архив печати",
+    printArchivesDescription: "Метаданные истории печати без файлов G-code и 3MF",
+    lastBackupAt: "Последняя копия:",
+    noBackupsYet: "Резервных копий пока нет",
+    next: "Следующая:",
+    startingBackup: "Запуск резервного копирования...",
+    test: "Проверить",
+    enableBackup: "Включить резервное копирование",
+    testConnection: "Проверить соединение",
+    enterRepoUrl: "Введите URL репозитория",
+    enterRepoAndToken: "Введите URL репозитория и токен доступа",
+    repoRequired: "Необходимо указать URL репозитория",
+    tokenRequired: "Необходимо указать токен доступа",
+    githubBackupEnabled: "Резервное копирование в GitHub включено",
+    tokenUpdated: "Токен обновлён",
+    settingsSaved: "Настройки сохранены",
+    failedToSave: "Не удалось сохранить: {{message}}",
+    backupCompleteFiles: "Резервное копирование завершено — обновлено файлов: {{count}}",
+    backupSkippedNoChanges: "Резервное копирование пропущено — изменений нет",
+    backupFailed2: "Ошибка резервного копирования: {{message}}",
+    clearedLogs: "Очищено записей журнала: {{count}}",
+    failedToClearLogs: "Не удалось очистить журнал: {{message}}",
+    history: "История",
+    clear: "Очистить",
+    date: "Дата",
+    status: "Статус",
+    commit: "Коммит",
+    localBackup: "Локальная резервная копия",
+    localBackupDescription: "Создать полную резервную копию данных Bambuddy, включая базу данных, архивы, загрузки и все файлы.",
+    downloadBackupLabel: "Скачать резервную копию",
+    completeBackupZip: "Полная копия: база данных и все файлы (ZIP)",
+    download: "Скачать",
+    preparingBackup: "Подготовка резервной копии...",
+    creatingArchive: "Создание архива резервной копии... Для больших архивов это может занять некоторое время.",
+    downloadingFile: "Скачивание файла резервной копии...",
+    backupDownloaded: "Резервная копия успешно скачана",
+    failedToCreateBackup: "Не удалось создать резервную копию: {{message}}",
+    restore: "Восстановить",
+    restoreReplacesAll: "Восстановление заменит все данные.",
+    restoreReplacesAllDetail: "Текущая база данных и файлы будут полностью заменены. После восстановления потребуется перезапуск.",
+    restoreConfirmTitle: "Восстановление из резервной копии",
+    restoreConfirmMessage: "Восстановить данные из файла «{{filename}}»? Текущая база данных и все файлы будут полностью заменены. После восстановления приложение потребуется перезапустить.",
+    restoreConfirmButton: "Восстановить",
+    uploadingFile: "Загрузка файла резервной копии...",
+    backupRestoredRestart: "Резервная копия восстановлена. Перезапустите Bambuddy.",
+    failedToRestore: "Не удалось восстановить резервную копию. Проверьте формат файла.",
+    reloadNow: "Перезагрузить сейчас",
+    creatingBackup: "Создание резервной копии",
+    restoringBackup: "Восстановление данных",
+    preparing: "Подготовка...",
+    processing: "Обработка...",
+    doNotClosePage: "Не закрывайте эту страницу и не переходите на другую. Для больших резервных копий операция может занять несколько минут.",
+    restoring: "Восстановление...",
+    restoreComplete: "Восстановление завершено",
+    restoreFailed2: "Ошибка восстановления",
+    importSettings: "Импортировать настройки из файла резервной копии",
+    pleaseWaitRestoring: "Подождите, пока данные восстанавливаются",
+    selectBackupFile: "Нажмите, чтобы выбрать файл резервной копии (.json или .zip)",
+    duplicateHandling: "Правила обработки дубликатов:",
+    matchPrinters: "Принтеры",
+    matchPrintersBy: "сопоставляются по серийному номеру",
+    matchSmartPlugs: "Умные розетки",
+    matchSmartPlugsBy: "сопоставляются по IP-адресу",
+    matchNotificationProviders: "Провайдеры уведомлений",
+    matchNotificationProvidersBy: "сопоставляются по имени",
+    matchFilaments: "Филаменты",
+    matchFilamentsBy: "сопоставляются по имени, типу и бренду",
+    matchArchives: "Архивы",
+    matchArchivesBy: "сопоставляются по хешу содержимого и всегда пропускаются",
+    matchPendingUploads: "Ожидающие загрузки",
+    matchPendingUploadsBy: "сопоставляются по имени файла",
+    matchSettingsTemplates: "Настройки и шаблоны",
+    matchSettingsTemplatesBy: "всегда перезаписываются",
+    replaceExisting: "Заменять существующие данные",
+    keepExisting: "Сохранять существующие данные",
+    overwriteDescription: "Перезаписывать существующие элементы данными из резервной копии",
+    keepDescription: "Восстанавливать только отсутствующие элементы",
+    overwriteCaution: "Внимание:",
+    overwriteWarning: "Перезапись заменит текущие настройки данными из резервной копии. В целях безопасности коды доступа к принтерам никогда не перезаписываются.",
+    cancel: "Отмена",
+    processingBackup: "Обработка файла резервной копии...",
+    itemsRestored: "Восстановлено элементов",
+    itemsSkipped: "Пропущено элементов",
+    restored: "Восстановлено",
+    skippedAlreadyExist: "Пропущено — уже существует",
+    filesCategory: "Файлы (3MF, миниатюры и т. п.)",
+    andMore: "...и ещё {{count}}",
+    newApiKeysGenerated: "Созданы новые API-ключи",
+    keysShownOnce: "Эти ключи показываются только один раз. Скопируйте их сейчас!",
+    copy: "Копировать",
+    noDataFound: "В резервной копии не найдено данных для восстановления.",
+    close: "Закрыть",
+    scheduledBackup: "Резервные копии по расписанию",
+    scheduledBackupDescription: "Автоматически создавать снимки резервных копий по расписанию. Каталог назначения можно смонтировать с NAS или внешнего накопителя.",
+    frequency: "Периодичность",
+    backupTime: "Время",
+    retention: "Хранение",
+    retentionDescription: "Количество сохраняемых резервных копий",
+    outputPath: "Каталог назначения",
+    outputPathPlaceholder: "По умолчанию: {{path}}",
+    outputPathDescription: "Оставьте пустым, чтобы использовать стандартный каталог",
+    runNow: "Запустить сейчас",
+    backupFiles: "Файлы резервных копий",
+    noScheduledBackups: "Резервных копий пока нет",
+    deleteBackup: "Удалить",
+    deleteBackupConfirm: "Удалить этот файл резервной копии?",
+    backupRunning: "Выполняется резервное копирование...",
+    scheduledBackupComplete: "Резервная копия успешно создана",
+    scheduledBackupFailed: "Ошибка резервного копирования",
+    nextBackup: "Следующая копия",
+    backupSize: "Размер",
+    localTimeHint: "Местное время ({{tz}})",
+    defaultPathLabel: "По умолчанию:",
+    pathCheck: {
+      title: "Bambuddy не может записывать в этот каталог",
+      howToFix: "Как исправить:",
+      sandboxed: "Служба Bambuddy не может записывать в {{path}}. Её модуль systemd работает с ProtectSystem=strict, поэтому все каталоги за пределами каталогов установки, данных и журналов доступны службе только для чтения, даже если из вашей командной оболочки запись разрешена.",
+      read_only: "{{path}} находится в файловой системе, доступной только для чтения.",
+      permission_denied: "Bambuddy не разрешена запись в {{path}}. Проверьте владельца каталога и права доступа.",
+      no_space: "В файловой системе, содержащей {{path}}, закончилось свободное место.",
+      not_a_directory: "{{path}} существует, но не является каталогом.",
+      missing: "{{path}} не существует, и создать этот каталог не удалось.",
+      error: "Bambuddy не может записывать в {{path}}.",
+      ephemeralTitle: "Эти резервные копии не сохранятся после перезапуска контейнера",
+      container_ephemeral: "{{path}} находится внутри контейнера Bambuddy, а не на хосте. Записанные туда резервные копии будут потеряны при пересоздании контейнера. Подключите каталог с хоста:",
+    },
+    categories: {
+      settings: "Настройки",
+      notification_providers: "Провайдеры уведомлений",
+      notification_templates: "Шаблоны уведомлений",
+      smart_plugs: "Умные розетки",
+      printers: "Принтеры",
+      filaments: "Филаменты",
+      maintenance_types: "Виды обслуживания",
+      archives: "Архивы",
+      projects: "Проекты",
+      pending_uploads: "Ожидающие загрузки",
+      external_links: "Внешние ссылки",
+      api_keys: "API-ключи",
+    },
+  },
+  tags: {
+    title: "Метки",
+    addTag: "Добавить метку",
+    editTag: "Изменить метку",
+    deleteTag: "Удалить метку",
+    tagName: "Название метки",
+    tagColor: "Цвет метки",
+    noTags: "Меток нет",
+    deleteConfirm: "Удалить эту метку?",
+    manageTags: "Управление метками",
+  },
+  uploadModal: {
+    title: "Загрузка файлов 3MF",
+    dragDrop: "Перетащите файлы .3mf сюда",
+    or: "или",
+    browseFiles: "Выбрать файлы",
+    extractionInfo: "Модель принтера будет автоматически определена по метаданным файла 3MF.",
+    uploaded: "загружено",
+    failed: "ошибок",
+    uploading: "Загрузка...",
+    upload: "Загрузить",
+    uploadFailed: "Не удалось загрузить",
+  },
+  editArchive: {
+    title: "Редактирование архива",
+    name: "Название",
+    namePlaceholder: "Название печати",
+    printer: "Принтер",
+    noPrinter: "Без принтера",
+    project: "Проект",
+    noProject: "Без проекта",
+    itemsPrinted: "Напечатано изделий",
+    itemsPrintedHelp: "Количество изделий, полученных в этом задании печати",
+    notes: "Заметки",
+    notesPlaceholder: "Добавьте заметки об этой печати...",
+    externalLink: "Внешняя ссылка",
+    externalLinkPlaceholder: "https://printables.com/model/...",
+    externalLinkHelp: "Ссылка на Printables, Thingiverse или другой источник",
+    tags: "Метки",
+    tagsPlaceholder: "Добавить метки...",
+    addMoreTags: "Добавить ещё метки...",
+    matchingTags: "Совпадения для «{{query}}»",
+    existingTags: "Существующие метки",
+    clickToAdd: "(нажмите, чтобы добавить)",
+    status: "Статус",
+    failureReason: "Причина сбоя",
+    selectReason: "Выберите причину...",
+    photos: "Фотографии результата печати",
+    photosHelp: "Нажмите +, чтобы добавить фотографии результата",
+    printResult: "Результат печати",
+    saving: "Сохранение...",
+    failureReasons: {
+      adhesionFailure: "Плохая адгезия к столу",
+      spaghettiDetached: "Спагетти / отрыв детали",
+      layerShift: "Сдвиг слоёв",
+      cloggedNozzle: "Засор сопла",
+      filamentRunout: "Закончился филамент",
+      warping: "Коробление",
+      stringing: "Стрингинг",
+      underExtrusion: "Недоэкструзия",
+      powerFailure: "Сбой питания",
+      userCancelled: "Отменено пользователем",
+      other: "Другое",
+    },
+    statuses: {
+      completed: "Завершено",
+      failed: "Ошибка",
+      aborted: "Отменено",
+      printing: "Печать",
+    },
+  },
+  kProfiles: {
+    title: "K-профили",
+    noPrintersConfigured: "Принтеры не настроены",
+    addPrinterInSettings: "Добавьте принтер в настройках, чтобы управлять K-профилями",
+    noActivePrinters: "Нет активных принтеров",
+    enablePrinterConnection: "Включите подключение к принтеру, чтобы просматривать его K-профили",
+    loadingProfiles: "Загрузка K-профилей...",
+    printerOffline: "Принтер не в сети",
+    printerOfflineDesc: "Выбранный принтер не подключён. Включите его, чтобы просмотреть K-профили.",
+    noMatchingProfiles: "Подходящие профили не найдены",
+    noMatchingProfilesDesc: "Ни один профиль не соответствует условиям поиска",
+    noKProfiles: "K-профили отсутствуют",
+    noKProfilesDesc: "Для сопла {{diameter}} мм не найдены профили Pressure Advance",
+    createFirstProfile: "Создать первый профиль",
+    printer: "Принтер",
+    nozzle: "Сопло",
+    refresh: "Обновить",
+    addProfile: "Добавить профиль",
+    export: "Экспорт",
+    import: "Импорт",
+    select: "Выбрать",
+    selectAll: "Выбрать все",
+    delete: "Удалить",
+    searchPlaceholder: "Поиск по названию или филаменту...",
+    allExtruders: "Все экструдеры",
+    leftOnly: "Только левый",
+    rightOnly: "Только правый",
+    allFlow: "Любой тип потока",
+    hfOnly: "Только HF",
+    sOnly: "Только S",
+    sortName: "Сортировка: название",
+    sortKValue: "Сортировка: K-значение",
+    sortFilament: "Сортировка: филамент",
+    leftExtruder: "Левый экструдер",
+    rightExtruder: "Правый экструдер",
+    modal: {
+      addTitle: "Добавить K-профиль",
+      editTitle: "Изменить K-профиль",
+      profileName: "Название профиля",
+      profileNamePlaceholder: "Мой профиль PLA",
+      kValue: "K-значение",
+      kValuePlaceholder: "0.020",
+      kValueHelp: "Типичный диапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
+      filament: "Филамент",
+      selectFilament: "Выберите филамент...",
+      noFilamentsHelp: "Филаменты не найдены. Сначала создайте K-профиль в Bambu Studio.",
+      flowType: "Тип потока",
+      highFlow: "Высокопоточный",
+      standard: "Стандартный",
+      nozzleSize: "Диаметр сопла",
+      extruder: "Экструдер",
+      extruders: "Экструдеры",
+      left: "Левый",
+      right: "Правый",
+      notes: "Заметки (хранятся локально)",
+      notesPlaceholder: "Добавьте заметки об этом профиле...",
+      notesHelp: "Заметки сохраняются в Bambuddy, а не на принтере",
+      syncing: "Синхронизация с принтером...",
+      savingExtruder: "Сохранение в экструдер {{current}}/{{total}}...",
+      pleaseWait: "Подождите",
+    },
+    deleteConfirm: {
+      title: "Удалить профиль",
+      cannotUndo: "Это действие нельзя отменить",
+      message: "Удалить профиль «{{name}}» с принтера?",
+    },
+    bulkDelete: {
+      title: "Удалить профили",
+      cannotUndo: "Это действие нельзя отменить",
+      message: "Удалить с принтера выбранные профили ({{count}})?",
+    },
+    toast: {
+      profileSaved: "K-профиль сохранён",
+      profilesSaved: "K-профиль сохранён для {{count}} экструдеров",
+      selectAtLeastOneExtruder: "Выберите хотя бы один экструдер",
+      profileDeleted: "K-профиль удалён",
+      profilesDeleted: "Удалено профилей: {{count}}",
+      exportedProfiles: "Экспортировано профилей: {{count}}",
+      importedProfiles: "Импортировано {{count}} из {{total}} профилей",
+      noProfilesToExport: "Нет профилей для экспорта",
+      invalidFileFormat: "Недопустимый формат файла",
+      failedToParseImport: "Не удалось разобрать файл импорта",
+      failedToSaveBatch: "Не удалось сохранить K-профили",
+      noteSaved: "Заметка сохранена",
+      failedToSaveNote: "Не удалось сохранить заметку",
+    },
+    permission: {
+      noRead: "У вас нет прав на обновление профилей",
+      noCreate: "У вас нет прав на добавление профилей",
+      noUpdate: "У вас нет прав на изменение K-профилей",
+      noDelete: "У вас нет прав на удаление K-профилей",
+      noExport: "У вас нет прав на экспорт профилей",
+      noImport: "У вас нет прав на импорт профилей",
+    },
+  },
+  virtualPrinter: {
+    title: "Виртуальный принтер",
+    running: "Запущен",
+    stopped: "Остановлен",
+    description: {
+      default: "Включает виртуальный принтер, который отображается в Bambu Studio и OrcaSlicer. Отправленные на него файлы сразу сохраняются в архив без печати.",
+      proxy: "Включает прокси, перенаправляющий трафик слайсера на реальный принтер и позволяющий печатать удалённо из любой сети.",
+    },
+    enable: {
+      title: "Включить виртуальный принтер",
+      visibleInSlicer: "Отображается как «Bambuddy» при обнаружении принтеров в слайсере",
+      proxyingTo: "Проксирование на {{name}}",
+      notActive: "Не активен",
+    },
+    model: {
+      title: "Модель принтера",
+      description: "Выберите модель принтера, которую нужно эмулировать.",
+      restartWarning: "При изменении модели виртуальный принтер будет перезапущен",
+    },
+    accessCode: {
+      title: "Код доступа",
+      isSet: "Код доступа задан",
+      notSet: "Код доступа не задан — он необходим для включения",
+      placeholder: "Введите 8-значный код",
+      placeholderChange: "Введите новый код",
+      hint: "Код должен состоять ровно из 8 символов. Слайсеры используют его для аутентификации.",
+      charCount: "({{count}}/8)",
+      inheritedFromTarget: "Унаследован от целевого принтера",
+      derivedFromTargetHint: "Используется код доступа целевого принтера. Мост перенаправляет аутентификацию слайсера на реальный принтер, поэтому коды должны совпадать. Чтобы изменить значение, измените код доступа самого принтера.",
+      reveal: "Показать код доступа",
+      hide: "Скрыть код доступа",
+    },
+    targetPrinter: {
+      title: "Целевой принтер",
+      configured: "Целевой принтер для прокси настроен",
+      notConfigured: "Целевой принтер не выбран — он необходим для режима прокси",
+      placeholder: "Выберите принтер...",
+      hint: "Выберите принтер, на который будет перенаправляться трафик слайсера. Принтер должен работать в режиме LAN.",
+      noPrinters: "Нет настроенных принтеров. Сначала добавьте принтер.",
+    },
+    remoteInterface: {
+      title: "Переопределение сетевого интерфейса",
+      configured: "Переопределение интерфейса активно",
+      optional: "Необязательно — используйте, если IP определён неверно, например при нескольких сетевых картах, Docker или VPN",
+      placeholder: "Определять автоматически...",
+      hint: "Переопределяет IP-адрес, публикуемый через SSDP и используемый в TLS-сертификате. Полезно, если у Bambuddy несколько сетевых интерфейсов.",
+    },
+    mode: {
+      title: "Режим",
+      archive: "Архив",
+      archiveDesc: "Сразу сохранять файлы в архив",
+      review: "Проверка",
+      reviewDesc: "Проверять перед добавлением в архив",
+      queue: "Очередь",
+      queueDesc: "Сохранять в архив и добавлять в очередь",
+      proxy: "Прокси",
+      proxyDesc: "Перенаправлять на реальный принтер",
+    },
+    autoDispatch: {
+      title: "Автоматический запуск",
+      description: "Автоматически запускать задания после добавления в очередь. Если выключено, задания ожидают ручного запуска.",
+    },
+    queueForceColorMatch: {
+      title: "Требовать совпадения цвета",
+      description: "Не назначать задание принтеру, если загружены филамент другого типа или другого цвета. По умолчанию выключено: без этой проверки очередь сопоставляет только модель принтера и может выбрать принтер с неподходящим цветом.",
+    },
+    gcodeInjection: {
+      title: "Вставка G-code",
+      description: "Применять к заданиям этого виртуального принтера фрагменты G-code для соответствующей модели, заданные в настройках. По умолчанию выключено.",
+    },
+    tailscaleDisabled: {
+      title: "Интеграция Tailscale",
+      description: "Отметить этот виртуальный принтер как доступный через Tailscale. Будет показан Tailscale-адрес хоста, который нужно указать в слайсере. Импорт сертификата центра сертификации выполняется как обычно; этот переключатель на сертификаты не влияет.",
+    },
+    setupRequired: {
+      title: "Требуется настройка",
+      description: "Для работы виртуального принтера требуется дополнительная настройка системы: перенаправление портов, правила межсетевого экрана и параметры, зависящие от платформы.",
+      readGuide: "Перед включением прочитайте руководство по настройке",
+    },
+    archiveNameSource: {
+      title: "Источник названия архива",
+      description: "Выберите способ формирования названия новых архивов для файлов, полученных через виртуальный принтер. «Метаданные» использует поле Title, встроенное слайсером в 3MF. «Имя файла» использует имя, переданное Bambu Studio по FTP. Bambu Studio заменяет введённое в окне отправки название значением Title из 3MF, если оно присутствует, поэтому оба режима часто дают одинаковый результат.",
+      metadata: "Метаданные",
+      filename: "Имя файла",
+    },
+    caCert: {
+      title: "Сертификат для слайсера",
+      description: "Виртуальные принтеры используют TLS-сертификат, подписанный центром сертификации Bambuddy. Один раз импортируйте этот сертификат CA в хранилище доверенных сертификатов слайсера, чтобы он принимал соединение.",
+      copy: "Копировать",
+      copied: "Скопировано",
+      download: "Скачать",
+      fingerprint: "SHA-256",
+    },
+    howItWorks: {
+      title: "Как это работает",
+      step1: "В одной локальной сети виртуальные принтеры автоматически появляются в Bambu Studio и OrcaSlicer. Из других сетей добавьте их вручную по IP-адресу и коду доступа.",
+      step2: "В режимах «Архив», «Проверка» и «Очередь» используйте кнопку «Отправить» в слайсере для загрузки 3MF в Bambuddy. Слайсер покажет сообщение об успешной печати, но файл будет только сохранён, без запуска печати.",
+      step3: "В режиме прокси виртуальный принтер перенаправляет весь трафик на реальный принтер — печать запускается сразу, как при прямом подключении.",
+    },
+    status: {
+      title: "Сведения о состоянии",
+      printerName: "Имя принтера",
+      model: "Модель",
+      serialNumber: "Серийный номер",
+      mode: "Режим",
+      pendingFiles: "Ожидающие файлы",
+      targetPrinter: "Целевой принтер",
+      ftpPort: "Порт FTP",
+      mqttPort: "Порт MQTT",
+      ftpConnections: "Соединения FTP",
+      mqttConnections: "Соединения MQTT",
+    },
+    toast: {
+      updated: "Настройки виртуального принтера обновлены",
+      failedToUpdate: "Не удалось обновить настройки",
+      copyFailed: "Не удалось скопировать — выделите текст вручную",
+      accessCodeRequired: "Сначала задайте код доступа",
+      targetPrinterRequired: "Сначала выберите целевой принтер",
+      bindIpRequired: "Сначала задайте IP-адрес привязки",
+      accessCodeEmpty: "Код доступа не может быть пустым",
+      accessCodeLength: "Код доступа должен состоять ровно из 8 символов",
+      targetCodeChangedRebind: "Код доступа приведён в соответствие с новым целевым принтером. Удалите и снова добавьте устройство в слайсере, чтобы он получил новый код.",
+      created: "Виртуальный принтер создан",
+      failedToCreate: "Не удалось создать виртуальный принтер",
+      deleted: "Виртуальный принтер удалён",
+      failedToDelete: "Не удалось удалить виртуальный принтер",
+    },
+    list: {
+      title: "Виртуальные принтеры",
+      add: "Добавить",
+      addFirst: "Добавить виртуальный принтер",
+      empty: "Виртуальные принтеры не настроены. Добавьте первый принтер.",
+    },
+    bindIp: {
+      title: "Интерфейс привязки",
+      placeholder: "Выберите интерфейс...",
+      hint: "Сетевой интерфейс, к которому будет привязан этот виртуальный принтер. Для каждого принтера он должен быть уникальным.",
+    },
+    proxy: {
+      accessCodeHint: "В режиме прокси используйте в слайсере код доступа целевого принтера. Соединение прозрачно перенаправляется на реальный принтер.",
+    },
+    addDialog: {
+      title: "Добавление виртуального принтера",
+      name: "Название",
+      hint: "После создания можно настроить код доступа, целевой принтер и остальные параметры.",
+      create: "Создать",
+    },
+    deleteConfirm: {
+      title: "Удаление виртуального принтера",
+      message: "Удалить «{{name}}»? Все службы этого виртуального принтера будут остановлены.",
+    },
+  },
+  modelViewer: {
+    openInSlicer: "Открыть в слайсере",
+    tabs: {
+      model: "3D-модель",
+      gcode: "Предпросмотр G-code",
+    },
+    notAvailable: "недоступно",
+    notSliced: "не нарезано",
+    plates: "Пластины",
+    allPlates: "Все пластины",
+    plateNumber: "Пластина {{number}}",
+    plateCount: "{{count}} пластина",
+    plateCount_other: "{{count}} пластин",
+    objectCount: "{{count}} объект",
+    objectCount_other: "{{count}} объектов",
+    filamentCount: "{{count}} филамент",
+    filamentCount_other: "{{count}} филаментов",
+    eta: "Осталось {{minutes}} мин",
+    noPreview: "Для этого файла нет предпросмотра",
+    pagination: {
+      pageOf: "Страница {{current}} из {{total}}",
+      prev: "Назад",
+      next: "Далее",
+    },
+    errors: {
+      failedToLoad: "Не удалось загрузить файл",
+      noMeshes: "В файле 3MF не найдены сеточные модели",
+      unsupportedFormat: "Неподдерживаемый формат файла",
+    },
+  },
+  maintenanceDescriptions: {
+    lubricateCarbonRods: "Смазать углепластиковые направляющие для плавного перемещения",
+    lubricateRails: "Смазать линейные направляющие для плавного перемещения",
+    cleanNozzle: "Очистить хотэнд и сопло для предотвращения засоров",
+    checkBelts: "Проверить натяжение ремней для сохранения точности печати",
+    cleanBuildPlate: "Очистить печатную пластину для улучшения адгезии",
+    checkExtruder: "Проверить шестерни экструдера на износ",
+    checkCooling: "Проверить исправность вентиляторов охлаждения",
+    generalInspection: "Общий осмотр принтера",
+    cleanCarbonRods: "Очистить углепластиковые направляющие для уменьшения трения",
+    lubricateSteelRods: "Смазать стальные направляющие для плавного перемещения",
+    cleanSteelRods: "Очистить стальные направляющие для уменьшения трения",
+    cleanLinearRails: "Протереть линейные направляющие от пыли и загрязнений",
+    checkPtfeTube: "Проверить PTFE-трубку на износ и повреждения",
+    replaceHepaFilter: "Заменить HEPA-фильтр",
+    replaceCarbonFilter: "Заменить угольный фильтр",
+    lubricateLeftNozzleRail: "Смазать направляющую левого сопла (серия H2)",
+  },
+  smartPlugs: {
+    offline: "Не в сети",
+    admin: "Управление",
+    openPlugAdminPage: "Открыть страницу управления розеткой",
+    deleteSmartPlug: "Удалить умную розетку",
+    turnOnSmartPlug: "Включить умную розетку",
+    turnOffSmartPlug: "Выключить умную розетку",
+    turnOn: "Включить",
+    turnOff: "Выключить",
+    addSmartPlug: {
+      scanningNetwork: "Сканирование сети...",
+      chooseEntity: "Выберите сущность...",
+      connectionFailed: "Ошибка подключения",
+      searchEntities: "Поиск сущностей...",
+      searchPowerSensors: "Поиск датчиков мощности...",
+      searchEnergySensors: "Поиск датчиков энергии...",
+      placeholders: {
+        plugName: "Розетка в гостиной",
+        mqttStateOnValue: "ON, true, 1",
+        mqttSameAsPower: "Та же тема, что для мощности, или другая",
+      },
+    },
+    linkedTo: "Связана с:",
+    monitorOnly: "Только мониторинг",
+    alerts: "Оповещения",
+    scheduleOn: "Включение в {{time}}",
+    scheduleOff: "Выключение в {{time}}",
+    on: "Включена",
+    off: "Выключена",
+    power: "Мощность",
+    kwhToday: "кВт·ч сегодня",
+    settings: "Настройки",
+    automationSettings: "Настройки автоматизации",
+    showInSwitchbar: "Показывать на панели переключателей",
+    quickAccessSidebar: "Быстрый доступ с боковой панели",
+    enabled: "Включено",
+    enableAutomation: "Включить автоматизацию для этой розетки",
+    autoOn: "Автовключение",
+    autoOnDescription: "Включать при начале печати",
+    autoOff: "Автовыключение",
+    autoOffDescription: "Выключать после завершения печати (однократно)",
+    autoOffPersistent: "Оставлять включённым",
+    autoOffPersistentDescription: "Не сбрасывать автовыключение после печати",
+    autoOffAfterDrying: "Выключать после сушки",
+    autoOffAfterDryingDescription: "Выключать после завершения сушки в AMS",
+    delayAfterDryingMinutes: "Задержка после сушки (мин)",
+    turnOffDelayMode: "Условие отложенного выключения",
+    time: "Время",
+    temp: "Температура",
+    delayMinutes: "Задержка (мин)",
+    tempThreshold: "Порог температуры (°C)",
+    tempThresholdDescription: "Выключать, когда сопло остынет ниже этой температуры",
+    edit: "Изменить",
+    deleteConfirm: "Удалить «{{name}}»? Это действие нельзя отменить.",
+    turnOnConfirm: "Включить «{{name}}»?",
+    turnOffConfirm: "Выключить «{{name}}»? Питание подключённого устройства будет отключено.",
+    failedToTurn: "Не удалось {{action}} «{{name}}»",
+    unknown: "Неизвестно",
+    addTitle: "Добавление умной розетки",
+    editTitle: "Редактирование умной розетки",
+    stopScanning: "Остановить сканирование",
+    discoverTasmota: "Найти устройства Tasmota",
+    foundDevices: "Найдено устройств: {{count}}. Нажмите для выбора:",
+    noDevicesFound: "Устройства Tasmota в сети не найдены",
+    haNotConfigured: "Home Assistant не настроен. Настройте его в разделе",
+    haSettingsPath: "Настройки → Сеть → Home Assistant",
+    selectEntity: "Выберите сущность *",
+    ipAddress: "IP-адрес *",
+    nameLabel: "Название *",
+    username: "Имя пользователя",
+    password: "Пароль",
+    authHint: "Оставьте пустым, если устройство Tasmota не требует аутентификации",
+    linkToPrinter: "Связать с принтером",
+    noPrinter: "Без принтера — только ручное управление",
+    linkingDescription: "Связь позволяет автоматически включать и выключать питание при начале и завершении печати",
+    powerAlerts: "Оповещения о мощности",
+    alertAbove: "Оповещать выше (Вт)",
+    alertBelow: "Оповещать ниже (Вт)",
+    alertDescription: "Отправлять уведомление при пересечении заданных порогов мощности. Оставьте поле пустым, чтобы отключить соответствующий порог.",
+    dailySchedule: "Ежедневное расписание",
+    turnOnAt: "Включать в",
+    turnOffAt: "Выключать в",
+    scheduleDescription: "Ежедневно включать и выключать розетку в указанное время. Оставьте поле пустым, чтобы не выполнять соответствующее действие.",
+    showOnPrinterCard: "Показывать в карточке принтера",
+    displayOnPrinterCard: "Показывать кнопку в карточке принтера",
+    connectedResult: "Подключено!",
+    deviceLabel: "Устройство: {{name}} — ",
+    stateLabel: "Состояние: {{state}}",
+    test: "Проверить",
+    delete: "Удалить",
+    save: "Сохранить",
+    add: "Добавить",
+    cancel: "Отмена",
+    failedToStartScan: "Не удалось запустить сканирование",
+    nameRequired: "Укажите название",
+    entityRequired: "Для розеток Home Assistant необходимо выбрать сущность",
+    mqttTopicRequired: "Для мониторинга мощности, энергии или состояния необходимо настроить хотя бы одну тему MQTT",
+    loadingEntities: "Загрузка сущностей...",
+    loading: "Загрузка...",
+    failedToLoadEntities: "Не удалось загрузить сущности: {{error}}",
+    noEntitiesMatching: "Сущности по запросу «{{search}}» не найдены",
+    noEntitiesAvailable: "Нет доступных сущностей",
+    searchingEntities: "Поиск по всем сущностям, найдено: {{count}}",
+    showingEntities: "Показаны switch, light и input_boolean, доступно: {{count}}",
+    energyMonitoringOptional: "Мониторинг энергии (необязательно)",
+    energyMonitoringHint: "Найдите и выберите датчики мощности и энергии.",
+    powerSensorW: "Датчик мощности (Вт)",
+    energyTodayKwh: "Энергия за сегодня (кВт·ч)",
+    totalEnergyKwh: "Общая энергия (кВт·ч)",
+    noMatchingSensors: "Подходящие датчики не найдены",
+    none: "Нет",
+    mqttNotConfigured: "Брокер MQTT не настроен. Укажите его адрес в разделе",
+    mqttSettingsPath: "Настройки → Сеть → Публикация MQTT",
+    mqttNotConfiguredSuffix: "Включать публикацию не обязательно — достаточно заполнить параметры брокера.",
+    mqttMonitorOnlyDescription: "Розетки MQTT получают данные мощности и энергии через подписку MQTT. Управление включением и выключением недоступно — используйте брокер MQTT или систему домашней автоматизации.",
+    powerMonitoring: "Мониторинг мощности",
+    energyMonitoring: "Мониторинг энергии",
+    stateMonitoring: "Мониторинг состояния",
+    optional: "необязательно",
+    topic: "Тема",
+    jsonPath: "Путь JSON",
+    multiplier: "Множитель",
+    onValue: "Значение «включено»",
+    mqttPowerHint: "Путь JSON извлекает значение из полезной нагрузки JSON, например «power_l1». Оставьте пустым, если тема публикует число напрямую.\nДля преобразования мВт→Вт используйте 0.001, для кВт→Вт — 1000.",
+    mqttEnergyHint: "Путь JSON извлекает значение из полезной нагрузки JSON. Оставьте пустым для числового значения напрямую.\nДля преобразования Вт·ч→кВт·ч используйте 0.001, для МВт·ч→кВт·ч — 1000.",
+    mqttStateHint: "Путь JSON извлекает значение из полезной нагрузки JSON. Оставьте пустым для значения напрямую.\nЗначение «включено» — точная строка, означающая включённое состояние. Оставьте пустым для автоматического распознавания ON, true и 1.",
+    restControl: "Управление",
+    restOnUrl: "URL включения",
+    restOffUrl: "URL выключения",
+    restOnBody: "Тело запроса включения",
+    restOffBody: "Тело запроса выключения",
+    restMethod: "Метод HTTP",
+    restHeaders: "Пользовательские заголовки (JSON)",
+    restStatusUrl: "URL состояния",
+    restStatusPath: "Путь JSON состояния",
+    restStatusOnValue: "Значение «включено»",
+    restPowerUrl: "URL мощности",
+    restPowerPath: "Путь JSON мощности",
+    restPowerMultiplier: "Множитель мощности",
+    restEnergyUrl: "URL энергии",
+    restEnergyPath: "Путь JSON энергии",
+    restEnergyTotalPath: "Путь JSON полной энергии",
+    restEnergyTotalMultiplier: "Множитель полной энергии",
+    restEnergyTotalPathHint: "например, aenergy.total",
+    restEnergyTotalHint: "Многие розетки, включая все устройства Shelly, передают только накопительный счётчик энергии, который никогда не сбрасывается. Его следует указывать здесь, а не в поле выше: если считать его суточным расходом, значение не сбросится в полночь, а данные за вчера и общий расход останутся пустыми. Bambuddy вычисляет расход за сегодня и вчера по этому счётчику; для накопления данных потребуется один-два дня. Shelly передаёт ватт-часы, поэтому используйте множитель 0.001.",
+    restEnergyMultiplier: "Множитель энергии",
+    restUrlRequired: "Для REST-розетки необходимо указать хотя бы один URL: включения или выключения",
+    restHeadersHint: "например, {\"Authorization\": \"Bearer your-token\"}",
+    restBodyHint: "например, ON или {\"state\": \"on\"}",
+    restStatusHint: "URL для опроса текущего состояния",
+    restPathHint: "например, state или data.power.status",
+    restPowerUrlHint: "Отдельный URL данных мощности; если пусто, используется URL состояния",
+    restEnergyUrlHint: "Отдельный URL данных энергии; если пусто, используется URL состояния",
+    restEnergyHint: "Для каждого значения можно использовать отдельный URL либо URL состояния. Множители используются для преобразования единиц, например 0.001 для Вт·ч→кВт·ч.",
+    testConnection: "Проверить подключение",
+    connectionSuccess: "Подключение успешно",
+    noSwitchesInSwitchbar: "На панели переключателей нет устройств",
+    enableSwitchbarHint: "Включите «Показывать на панели переключателей» в разделе «Настройки → Умные розетки»",
+  },
+  notifications: {
+    providerTypes: {
+      callmebot: "CallMeBot/WhatsApp",
+      ntfy: "ntfy",
+      pushover: "Pushover",
+      telegram: "Telegram",
+      email: "Электронная почта",
+      discord: "Discord",
+      webhook: "Вебхук",
+      homeassistant: "Home Assistant",
+    },
+    providerDescriptions: {
+      email: "Уведомления по электронной почте через SMTP",
+      telegram: "Уведомления через Telegram-бота",
+      discord: "Отправка в канал Discord через вебхук",
+      ntfy: "Бесплатные push-уведомления с возможностью самостоятельного размещения сервера",
+      pushover: "Простые и надёжные push-уведомления",
+      callmebot: "Бесплатные уведомления WhatsApp через CallMeBot",
+      webhook: "Универсальный HTTP POST-запрос на любой URL",
+      homeassistant: "Постоянные уведомления на панели Home Assistant",
+    },
+    lastSuccess: "Последняя отправка: {{date}}",
+    error: "Ошибка",
+    printer: "Принтер:",
+    allPrinters: "Все принтеры",
+    sendTestNotification: "Отправить тестовое уведомление",
+    eventSettings: "Настройки событий",
+    enabled: "Включено",
+    sendFromProvider: "Отправлять уведомления через этого провайдера",
+    printEvents: "События печати",
+    printerStatus: "Состояние принтера",
+    amsAlarms: "Предупреждения AMS",
+    amsHtAlarms: "Предупреждения AMS-HT",
+    printQueue: "Очередь печати",
+    start: "Запуск",
+    plateCheck: "Проверка пластины",
+    complete: "Завершено",
+    failed: "Ошибка",
+    stopped: "Остановлено",
+    progress: "Ход печати",
+    offline: "Не в сети",
+    lowFilament: "Мало филамента",
+    maintenance: "Обслуживание",
+    amsHumidity: "Влажность AMS",
+    amsTemp: "Температура AMS",
+    amsHtHumidity: "Влажность AMS-HT",
+    amsHtTemp: "Температура AMS-HT",
+    bedCooled: "Стол остыл",
+    firstLayer: "Первый слой",
+    quiet: "Тихий режим",
+    digest: "Сводка в {{time}}",
+    printStarted: "Печать началась",
+    plateNotEmpty: "Пластина не пуста",
+    plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
+    printCompleted: "Печать завершена",
+    bedCooledLabel: "Стол остыл",
+    bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",
+    firstLayerCompleteLabel: "Первый слой завершён",
+    firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
+    missingSpoolAssignmentLabel: "Катушка не назначена",
+    missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
+    printFailed: "Ошибка печати",
+    printStopped: "Печать остановлена",
+    progressMilestones: "Этапы выполнения",
+    progressMilestonesDescription: "Уведомлять при 25%, 50% и 75%",
+    printerOffline: "Принтер не в сети",
+    printerError: "Ошибка принтера",
+    aiFailureDetection: "Обнаружение сбоев с помощью ИИ",
+    aiFailureDetectionDescription: "Уведомить, если Obico AI обнаружит возможный сбой печати",
+    lowFilamentLabel: "Заканчивается филамент",
+    maintenanceDue: "Требуется обслуживание",
+    maintenanceDueDescription: "Уведомить, когда принтеру потребуется обслуживание",
+    amsHumidityHigh: "Высокая влажность в AMS",
+    amsHumidityHighDescription: "Влажность в обычной AMS превысила заданный порог",
+    amsTemperatureHigh: "Высокая температура в AMS",
+    amsTemperatureHighDescription: "Температура в обычной AMS превысила заданный порог",
+    amsHtHumidityHigh: "Высокая влажность в AMS-HT",
+    amsHtHumidityHighDescription: "Влажность в AMS-HT превысила заданный порог",
+    amsHtTemperatureHigh: "Высокая температура в AMS-HT",
+    amsHtTemperatureHighDescription: "Температура в AMS-HT превысила заданный порог",
+    inventoryAlerts: "Уведомления о запасах",
+    stockReorderAlert: "Пора заказывать",
+    stockReorderAlertDescription: "Запас по SKU достиг точки повторного заказа",
+    stockBreakAlert: "Риск исчерпания запаса",
+    stockBreakAlertDescription: "Запас закончится раньше, чем поступит пополнение",
+    jobAdded: "Задание добавлено",
+    jobAddedDescription: "Задание добавлено в очередь",
+    jobAssigned: "Задание назначено",
+    jobAssignedDescription: "Задание по модели назначено принтеру",
+    jobStarted: "Задание запущено",
+    jobStartedDescription: "Задание из очереди отправлено на печать",
+    jobWaiting: "Задание ожидает",
+    jobWaitingDescription: "Задание ожидает филамент или свободный принтер",
+    jobSkipped: "Задание пропущено",
+    jobSkippedDescription: "Задание пропущено из-за ошибки предыдущего",
+    jobFailed: "Не удалось запустить задание",
+    jobFailedDescription: "Задание не удалось отправить на печать",
+    queueComplete: "Очередь завершена",
+    queueCompleteDescription: "Все задания в очереди завершены",
+    quietHours: "Тихие часы",
+    noNotificationsDuring: "Не отправлять уведомления в это время",
+    editProviderToChangeQuietHours: "Измените провайдера, чтобы настроить тихие часы",
+    dailyDigest: "Ежедневная сводка",
+    batchNotifications: "Объединять уведомления в одну ежедневную сводку",
+    sendAt: "Отправлять в {{time}}",
+    editProviderToChangeDigestTime: "Измените провайдера, чтобы настроить время сводки",
+    edit: "Изменить",
+    deleteProvider: "Удалить провайдера уведомлений",
+    deleteConfirm: "Удалить «{{name}}»? Это действие нельзя отменить.",
+    delete: "Удалить",
+    addTitle: "Добавить провайдера уведомлений",
+    editTitle: "Изменить провайдера уведомлений",
+    nameLabel: "Название *",
+    namePlaceholder: "Мои уведомления",
+    providerTypeLabel: "Тип провайдера *",
+    configuration: "Настройка",
+    testConfiguration: "Проверить настройку",
+    printerFilter: "Фильтр принтеров",
+    onlyFromPrinter: "Отправлять уведомления только о событиях этого принтера",
+    quietHoursDnd: "Тихие часы (не беспокоить)",
+    quietStart: "Начало",
+    quietEnd: "Окончание",
+    dailyDigestLabel: "Ежедневная сводка",
+    sendDigestAt: "Отправлять сводку в",
+    digestCollected: "События будут собраны и отправлены одной сводкой в указанное время",
+    notificationEvents: "События для уведомлений",
+    progressPercent: "(25%, 50%, 75%)",
+    bedCooledAfterPrint: "(после завершения печати)",
+    eventPriority: {
+      sectionTitle: "Приоритет ntfy",
+      helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.",
+      min: "Минимальный",
+      low: "Низкий",
+      default: "Обычный",
+      high: "Высокий",
+      urgent: "Срочный",
+    },
+    cancel: "Отмена",
+    save: "Сохранить",
+    add: "Добавить",
+    nameRequired: "Укажите название",
+    fieldRequired: "Поле «{{field}}» обязательно",
+    phoneNumber: "Номер телефона",
+    apiKey: "Ключ API",
+    serverUrl: "URL сервера",
+    topic: "Тема",
+    authToken: "Токен авторизации",
+    userKey: "Ключ пользователя",
+    appToken: "Токен приложения",
+    priority: "Приоритет",
+    pushoverRetry: "Повтор экстренного уведомления (с)",
+    pushoverExpire: "Срок действия экстренного уведомления (с)",
+    botToken: "Токен бота",
+    chatId: "ID чата",
+    smtpServer: "SMTP-сервер",
+    smtpPort: "Порт SMTP",
+    security: "Защита соединения",
+    authentication: "Аутентификация",
+    username: "Имя пользователя",
+    password: "Пароль",
+    fromEmail: "Адрес отправителя",
+    toEmail: "Адрес получателя",
+    webhookUrl: "URL вебхука",
+    payloadFormat: "Формат данных",
+    authorization: "Авторизация",
+    titleFieldName: "Имя поля заголовка",
+    messageFieldName: "Имя поля сообщения",
+    editTemplate: "Изменить шаблон: {{name}}",
+    titleLabel: "Заголовок",
+    bodyLabel: "Текст",
+    titlePlaceholder: "Заголовок уведомления...",
+    bodyPlaceholder: "Текст уведомления...",
+    availableVariables: "Доступные переменные",
+    clickToInsert: "Нажмите, чтобы вставить в позицию курсора в тексте",
+    livePreview: "Предварительный просмотр",
+    hide: "Скрыть",
+    show: "Показать",
+    loadingPreview: "Загрузка предварительного просмотра...",
+    enterTemplateContent: "Введите содержимое шаблона для предварительного просмотра",
+    titlePreview: "Заголовок:",
+    bodyPreview: "Текст:",
+    resetToDefault: "Восстановить стандартный шаблон",
+    titleRequired: "Укажите заголовок",
+    bodyRequired: "Введите текст уведомления",
+    notificationLog: "Журнал уведомлений",
+    showFailedOnly: "Только неудачные",
+    last24Hours: "Последние 24 часа",
+    last7Days: "Последние 7 дней",
+    last30Days: "Последние 30 дней",
+    last90Days: "Последние 90 дней",
+    justNow: "Только что",
+    noFailedNotifications: "Неудачных уведомлений нет",
+    noNotificationsLogged: "В журнале пока нет уведомлений",
+    unknownProvider: "Неизвестный провайдер",
+    logTitle: "Заголовок",
+    logMessage: "Сообщение",
+    logError: "Ошибка",
+    logProvider: "Провайдер: {{type}}",
+    logTime: "Время: {{time}}",
+    refresh: "Обновить",
+    clearOld: "Удалить старые",
+    statsSummary: "За последние {{days}} дней:",
+    statsNotifications: "уведомлений",
+    statsSent: "отправлено: {{count}}",
+    statsFailed: "ошибок: {{count}}",
+    eventTypes: {
+      print_start: "Печать началась",
+      print_complete: "Печать завершена",
+      print_failed: "Ошибка печати",
+      print_stopped: "Печать остановлена",
+      print_progress: "Ход печати",
+      printer_offline: "Принтер не в сети",
+      printer_error: "Ошибка принтера",
+      filament_low: "Заканчивается филамент",
+      maintenance_due: "Требуется обслуживание",
+      test: "Тест",
+    },
+    userEmail: {
+      title: "Уведомления",
+      emailNotifications: "Уведомления по электронной почте",
+      emailNotificationsDesc: "Получать письма о собственных заданиях печати. Для отправки используются системные параметры SMTP из раздела расширенной аутентификации.",
+      sendingTo: "Уведомления будут отправляться на адрес",
+      noEmailWarning: "Для вашей учётной записи не указан адрес электронной почты. Обратитесь к администратору.",
+      printJobNotifications: "Уведомления о заданиях печати",
+      printJobNotificationsDesc: "Выберите события, при которых будут отправляться письма о запущенных вами заданиях.",
+      printJobStarts: "Запуск задания печати",
+      printJobStartsDesc: "Уведомлять о начале вашего задания печати.",
+      printJobFinishes: "Завершение задания печати",
+      printJobFinishesDesc: "Уведомлять об успешном завершении вашего задания печати.",
+      printErrors: "Ошибки печати",
+      printErrorsDesc: "Уведомлять, если задание завершилось ошибкой или при печати возникла проблема.",
+      printJobStops: "Остановка задания печати",
+      printJobStopsDesc: "Уведомлять об отмене или остановке вашего задания печати.",
+      saveSuccess: "Настройки уведомлений сохранены.",
+      saveError: "Не удалось сохранить настройки уведомлений.",
+    },
+  },
+  richTextEditor: {
+    bold: "Полужирный",
+    italic: "Курсив",
+    underline: "Подчёркивание",
+    bulletList: "Маркированный список",
+    numberedList: "Нумерованный список",
+    alignLeft: "По левому краю",
+    alignCenter: "По центру",
+    alignRight: "По правому краю",
+    addLink: "Добавить ссылку",
+    removeLink: "Удалить ссылку",
+  },
+  externalLinks: {
+    title: "Ссылки на боковой панели",
+    sidebarLayout: "Боковая панель",
+    sidebarLayoutDescription: "Показывайте или скрывайте встроенные страницы, добавляйте внешние ссылки и перетаскивайте пункты, меняя порядок навигации.",
+    systemPages: "Страницы Bambuddy",
+    externalLinks: "Внешние ссылки",
+    visibleInSidebar: "Показывается на боковой панели",
+    hiddenFromSidebar: "Скрыто с боковой панели",
+    requiredInSidebar: "Обязательный пункт боковой панели",
+    hidePage: "Скрыть страницу",
+    showPage: "Показать страницу",
+    settingsCannotBeHidden: "Настройки нельзя скрыть",
+    noLinksConfigured: "Внешние ссылки не настроены",
+    deleteLink: "Удалить ссылку",
+    removeCustomIcon: "Удалить пользовательский значок",
+    openInNewTab: "Открывать в новой вкладке",
+    placeholders: {
+      linkName: "Моя ссылка",
+    },
+  },
+  keyboardShortcuts: {
+    title: "Горячие клавиши",
+    navigation: "Навигация",
+    archivesSection: "Архив",
+    kProfilesSection: "K-профили",
+    generalSection: "Общие",
+    shortcuts: {
+      goToPrinters: "Перейти к принтерам",
+      goToArchives: "Перейти в архив",
+      goToQueue: "Перейти к очереди",
+      goToStats: "Перейти к статистике",
+      goToProfiles: "Перейти к облачным профилям",
+      goToSettings: "Перейти к настройкам",
+      focusSearch: "Перейти в строку поиска",
+      openUploadModal: "Открыть окно загрузки",
+      clearSelection: "Снять выделение / убрать фокус с поля",
+      contextMenu: "Контекстное меню карточки",
+      refreshProfiles: "Обновить профили",
+      newProfile: "Новый профиль",
+      exitSelectionMode: "Выйти из режима выделения",
+      showHelp: "Показать эту справку",
+    },
+    footer: "Нажмите Esc или щёлкните снаружи, чтобы закрыть",
+  },
+  notificationLog: {
+    title: "Журнал уведомлений",
+    events: {
+      printStarted: "Печать началась",
+      printComplete: "Печать завершена",
+      printFailed: "Ошибка печати",
+      printStopped: "Печать остановлена",
+      progress: "Ход выполнения",
+      printerOffline: "Принтер не в сети",
+      printerError: "Ошибка принтера",
+      lowFilament: "Заканчивается филамент",
+      maintenanceDue: "Требуется обслуживание",
+      test: "Тест",
+    },
+    timeAgo: {
+      justNow: "Только что",
+      minutesAgo: "{{minutes}} мин назад",
+      hoursAgo: "{{hours}} ч назад",
+    },
+  },
+  restoreBackup: {
+    title: "Восстановление резервной копии",
+    restoring: "Восстановление...",
+    restoreComplete: "Восстановление завершено",
+    restoreFailed: "Ошибка восстановления",
+    importSettings: "Импортировать настройки из файла резервной копии",
+    pleaseWait: "Подождите, пока данные восстанавливаются",
+    clickToSelect: "Нажмите, чтобы выбрать файл резервной копии (.json или .zip)",
+    howDuplicateHandling: "Как обрабатываются совпадения:",
+    categories: {
+      printers: "Принтеры",
+      smartPlugs: "Умные розетки",
+      notificationProviders: "Провайдеры уведомлений",
+      filaments: "Филаменты",
+      archives: "Архив",
+      pendingUploads: "Ожидающие загрузки",
+      settingsTemplates: "Настройки и шаблоны",
+    },
+    matchingInfo: {
+      printers: "сопоставляются по серийному номеру",
+      smartPlugs: "сопоставляются по IP-адресу",
+      notificationProviders: "сопоставляются по названию",
+      filaments: "сопоставляются по названию, типу и бренду",
+      archives: "сопоставляются по хешу содержимого",
+      pendingUploads: "сопоставляются по имени файла",
+      settingsTemplates: "всегда перезаписываются",
+    },
+    replaceExisting: "Заменить существующие данные",
+    keepExisting: "Сохранить существующие данные",
+    replaceDescription: "Перезаписать уже существующие элементы данными из резервной копии",
+    keepDescription: "Восстановить только отсутствующие элементы",
+    caution: "Внимание:",
+    cautionText: "При перезаписи текущая конфигурация будет заменена данными из резервной копии. В целях безопасности коды доступа к принтерам никогда не перезаписываются.",
+    itemsRestored: "Восстановлено элементов",
+    itemsSkipped: "Пропущено элементов",
+    restored: "Восстановлено",
+    skipped: "Пропущено (уже существует)",
+    filesLabel: "Файлы (3MF, миниатюры и т. п.)",
+    newApiKeysGenerated: "Созданы новые ключи API",
+    newApiKeysWarning: "Эти ключи показываются только один раз. Скопируйте их сейчас!",
+    processingBackup: "Обработка резервной копии...",
+    noDataFound: "В резервной копии не найдено данных для восстановления.",
+    failedToRestore: "Не удалось восстановить резервную копию. Проверьте формат файла.",
+  },
+  backupExport: {
+    title: "Экспорт резервной копии",
+    selectData: "Выберите данные для включения",
+    selectAll: "Выбрать всё",
+    selectNone: "Снять выделение",
+    categoryDescriptions: {
+      settings: "Язык, тема и настройки обновлений",
+      notifications: "ntfy, Pushover, Discord и другие",
+      templates: "Пользовательские шаблоны сообщений",
+      smartPlugs: "Конфигурация розеток Tasmota",
+      externalLinks: "Ссылки на внешние сервисы в боковой панели",
+      printers: "Сведения о принтерах (без кодов доступа)",
+      plateDetection: "Эталонные изображения пустых пластин",
+      filaments: "Типы и стоимость филамента",
+      maintenance: "Пользовательские графики обслуживания",
+      archives: "Все данные печати и файлы (3MF, миниатюры, фотографии)",
+      projects: "Проекты, позиции спецификаций и вложения",
+      pendingUploads: "Загрузки виртуальных принтеров, ожидающие проверки",
+      apiKeys: "Ключи API вебхуков (при импорте создаются новые)",
+    },
+    requiresPrinters: "Необходимо выбрать раздел «Принтеры»",
+    zipFileWarning: "Будет создан ZIP-файл.",
+    zipFileDescription: "В него войдут все файлы 3MF, миниатюры, таймлапсы и фотографии. Операция может занять некоторое время, а файл — получиться большим.",
+    includeAccessCodes: "Включить коды доступа",
+    includeAccessCodesDescription: "Для переноса на другой компьютер",
+    includeAccessCodesWarning: "Коды доступа попадут в файл открытым текстом. Храните резервную копию в безопасном месте!",
+    categoriesSelected: "Выбрано категорий: {{selectedCount}}",
+  },
+  pendingUploads: {
+    placeholders: {
+      notes: "Добавьте примечания об этой печати...",
+    },
+    discardUpload: "Отклонить загрузку",
+    archiveAllUploads: "Добавить все загрузки в архив",
+    discardAllUploads: "Отклонить все загрузки",
+    archive: "В архив",
+    timeAgo: {
+      justNow: "Только что",
+      minutesAgo: "{{minutes}} мин назад",
+      hoursAgo: "{{hours}} ч назад",
+      daysAgo: "{{days}} дн. назад",
+    },
+  },
+  apiBrowser: {
+    placeholders: {
+      requestBody: "Тело запроса JSON...",
+      searchEndpoints: "Поиск конечных точек...",
+    },
+  },
+  configureAmsSlot: {
+    title: "Настройка слота AMS",
+    slotConfigured: "Слот настроен!",
+    configuringSlot: "Настраивается слот:",
+    slotLabel: "{{ams}}, слот {{slot}}",
+    searchPresets: "Поиск профилей...",
+    colorPlaceholder: "Название или HEX-код цвета (например: brown, FF8800)",
+    clearCustomColor: "Очистить пользовательский цвет",
+    noCloudPresets: "Облачных профилей нет. Войдите в Bambu Cloud для синхронизации.",
+    noPresetsAvailable: "Нет доступных профилей. Войдите в Bambu Cloud или импортируйте локальные профили.",
+    noMatchingPresets: "Подходящие профили не найдены.",
+    custom: "Пользовательский",
+    builtin: "Встроенный",
+    orcaCloud: "Orca Cloud",
+    bambuCloud: "Bambu Cloud",
+    settingsSentToPrinter: "Настройки отправлены на принтер",
+    filamentProfile: "Профиль филамента",
+    kProfileLabel: "K-профиль (Pressure Advance)",
+    filteringFor: "Фильтр по материалу: {{material}}",
+    noKProfile: "Без K-профиля (стандартное значение 0,020)",
+    noMatchingKProfiles: "Подходящие K-профили не найдены. Будет использовано стандартное значение K=0,020.",
+    selectFilamentFirst: "Сначала выберите профиль филамента",
+    kFromCalibration: "K={{value}} из калибровки принтера",
+    customColorLabel: "Пользовательский цвет (необязательно)",
+    presetColors: "Цвета {{name}}:",
+    showLessColors: "Показать меньше цветов",
+    showMoreColors: "Показать больше цветов",
+    clear: "Очистить",
+    hexLabel: "HEX: #{{hex}}",
+    resetting: "Сброс...",
+    resetSlot: "Сбросить слот",
+    cancel: "Отмена",
+    configuring: "Настройка...",
+    configureSlot: "Настроить слот",
+  },
+  githubBackup: {
+    title: "Резервное копирование в Git",
+    history: "История",
+    downloadBackup: "Скачать резервную копию",
+    restoreBackup: "Восстановить резервную копию",
+    noBackupsYet: "Резервных копий пока нет",
+  },
+  emailSettings: {
+    placeholders: {
+      fromName: "BamBuddy",
+    },
+  },
+  tagManagement: {
+    searchTags: "Поиск меток...",
+    renameTag: "Переименовать метку",
+    deleteTag: "Удалить метку",
+  },
+  notificationTemplates: {
+    placeholders: {
+      title: "Заголовок уведомления...",
+      body: "Текст уведомления...",
+    },
+  },
+  batchTag: {
+    placeholders: {
+      newTag: "Введите новую метку...",
+    },
+  },
+  photoGallery: {
+    deletePhoto: "Удалить фотографию",
+  },
+  filamentHoverCard: {
+    copySpoolUuid: "Скопировать UUID катушки",
+  },
+  kProfilesView: {
+    hasNote: "Есть примечание",
+    copyProfile: "Копировать профиль",
+  },
+  layout: {
+    openMenu: "Открыть меню",
+    noPermissionSystemInfo: "У вас нет разрешения на просмотр системной информации",
+  },
+  dashboard: {
+    dragToReorder: "Перетащите, чтобы изменить порядок",
+    hideWidget: "Скрыть виджет",
+  },
+  notificationProviderCard: {
+    deleteNotificationProvider: "Удалить провайдера уведомлений",
+  },
+  fileManagerModal: {
+    closeFileManager: "Закрыть файловый менеджер",
+    sortFiles: "Сортировать файлы",
+    goToParentFolder: "Перейти в родительскую папку",
+    threeView: "Просмотр 3D",
+  },
+  embeddedCameraViewer: {
+    refreshStream: "Обновить поток",
+    close: "Закрыть",
+    zoomOut: "Уменьшить",
+    resetZoom: "Сбросить масштаб",
+    zoomIn: "Увеличить",
+    dragToResize: "Перетащите, чтобы изменить размер",
+  },
+  timelapseViewer: {
+    skipBack5s: "Назад на 5 с",
+    skipForward5s: "Вперёд на 5 с",
+  },
+  notificationProviders: {
+    descriptions: {
+      email: "Уведомления по электронной почте через SMTP",
+      telegram: "Уведомления через Telegram-бота",
+      discord: "Отправка в канал Discord через вебхук",
+      ntfy: "Бесплатные push-уведомления с возможностью самостоятельного размещения сервера",
+      pushover: "Простые и надёжные push-уведомления",
+      callmebot: "Бесплатные уведомления WhatsApp через CallMeBot",
+      webhook: "Обычный HTTP POST на любой URL",
+    },
+  },
+  logViewer: {
+    searchPlaceholder: "Поиск по сообщению или имени логгера...",
+    noLogEntries: "Записи журнала не найдены",
+  },
+  switchbarPopover: {
+    noSwitchesInSwitchbar: "На панели переключателей нет устройств",
+  },
+  projectPageModal: {
+    placeholders: {
+      title: "Название",
+      designer: "Автор",
+      license: "Лицензия",
+      description: "Введите описание...",
+      profileTitle: "Название профиля",
+      profileDescription: "Описание профиля...",
+    },
+  },
+  spoolmanSettings: {
+  },
+  time: {
+    unknown: "—",
+    waiting: "Ожидание",
+    justNow: "Только что",
+    now: "Сейчас",
+    minsAgo: "{{count}} мин назад",
+    inMins: "через {{count}} мин",
+    hoursAgo: "{{count}} ч назад",
+    inHours: "через {{count}} ч",
+    daysAgo: "{{count}} дн. назад",
+    inDays: "через {{count}} дн.",
+  },
+  spoolbuddy: {
+    nav: {
+      dashboard: "Панель",
+      ams: "AMS",
+      inventory: "Катушки",
+      writeTag: "Запись метки",
+      settings: "Настройки",
+    },
+    status: {
+      nfcReady: "NFC готов",
+      nfcOff: "NFC выключен",
+      offline: "Не в сети",
+      online: "В сети",
+      noPrinters: "Нет принтеров",
+      deviceOffline: "Устройство не в сети",
+      waitingConnection: "Ожидание подключения устройства...",
+      systemReady: "Система готова",
+      status: "Состояние",
+    },
+    dashboard: {
+      readyToScan: "Готово к сканированию",
+      idleMessage: "Поставьте катушку на весы для идентификации",
+      nfcHint: "Метка NFC будет считана автоматически",
+      device: "Устройство",
+      syncWeight: "Синхронизировать массу",
+      weightSynced: "Синхронизировано!",
+      unknownTag: "Неизвестная метка",
+      newTag: "Обнаружена новая метка",
+      onScale: "на весах",
+      linkSpool: "Связать с катушкой",
+      linkTagTitle: "Связать метку с катушкой",
+      linkTag: "Связать метку",
+      selectSpool: "Выберите катушку, с которой нужно связать эту метку:",
+      noUntagged: "Катушки без меток не найдены",
+      tagDetected: "Метка обнаружена",
+      noTag: "Метка отсутствует",
+      tagId: "Метка",
+      grossWeight: "Масса брутто",
+      spoolSize: "Размер катушки",
+      close: "Закрыть",
+      currentSpool: "Текущая катушка",
+      plateReady: "Пластина готова: {{name}}",
+      plateReadyLabel: "Пластины, готовые к очистке",
+      plateClearAction: "Очищено",
+      plateClearedToast: "Пластина отмечена как очищенная",
+      plateClearFailed: "Не удалось отметить пластину как очищенную",
+    },
+    modal: {
+      spoolDetected: "Катушка обнаружена",
+      assignToAms: "Назначить в AMS",
+      syncWeight: "Синхронизировать массу",
+      weightSynced: "Синхронизировано!",
+      syncing: "Синхронизация...",
+      newTagDetected: "Обнаружена новая метка",
+      addToInventory: "Добавить в учёт",
+      assignToAmsTitle: "Назначить в AMS",
+      selectSlot: "Выберите слот",
+      assign: "Назначить",
+      assigning: "Назначение...",
+      assignSuccess: "Назначено!",
+      assignPendingInsert: "Назначено. Слот будет настроен после установки катушки.",
+      assignError: "Не удалось назначить катушку. Повторите попытку.",
+      noPrinterSelected: "Выберите принтер...",
+      noAmsDetected: "На этом принтере AMS не обнаружена",
+      slot: "Слот",
+    },
+    weight: {
+      noReading: "Нет показаний",
+      stable: "Стабильно",
+      measuring: "Измерение...",
+      tare: "Тарировать",
+      calibrate: "Калибровать",
+    },
+    spool: {
+      remaining: "Остаток",
+      material: "Материал",
+      brand: "Бренд",
+      color: "Цвет",
+      coreWeight: "Сердечник",
+      labelWeight: "По этикетке",
+      scaleWeight: "По весам",
+      netWeight: "Нетто",
+      lastUsed: "Последнее использование",
+    },
+    ams: {
+      noData: "AMS не обнаружена",
+      connectAms: "Подключите AMS, чтобы увидеть слоты филамента",
+      noPrinter: "Принтер не выбран",
+      selectPrinter: "Выберите принтер на верхней панели",
+      printerDisconnected: "Принтер отключён",
+      humidity: "Влажность",
+      level: "Уровень",
+      active: "Активен",
+      slot: "Слот",
+      empty: "Пусто",
+    },
+    inventory: {
+      search: "Поиск катушек...",
+      empty: "В учёте нет катушек",
+      noResults: "Подходящие катушки не найдены",
+      spools: "катушек",
+      addSpool: "Добавить катушку",
+    },
+    settings: {
+      tabDevice: "Устройство",
+      tabDisplay: "Экран",
+      tabScale: "Весы",
+      tabUpdates: "Обновления",
+      nfcReader: "Считыватель NFC",
+      type: "Тип",
+      connection: "Подключение",
+      notConnected: "Н/Д",
+      deviceInfo: "Сведения об устройстве",
+      hostname: "Хост",
+      uptime: "Время работы",
+      systemConfig: "Бэкенд и авторизация",
+      backendUrl: "URL бэкенда Bambuddy",
+      apiToken: "Токен API",
+      apiTokenPlaceholder: "Введите токен API",
+      saveConfig: "Сохранить конфигурацию",
+      systemQueued: "Конфигурация поставлена в очередь.",
+      nfcDiagnostic: "Диагностика NFC",
+      scaleDiagnostic: "Диагностика весов",
+      readTagDiagnostic: "Диагностика чтения метки",
+      testNfc: "Проверить считыватель",
+      testScale: "Проверить точность",
+      testReadTag: "Считать метку",
+      systemFieldsRequired: "Необходимо указать URL бэкенда.",
+      brightness: "Яркость",
+      saved: "Сохранено",
+      noBacklight: "Подсветка DSI не обнаружена. Для управления яркостью требуется дисплей DSI.",
+      screenBlank: "Тайм-аут отключения экрана",
+      screenBlankDesc: "Экран выключается при бездействии. Коснитесь его для пробуждения.",
+      displayNote: "Яркость регулируется программным фильтром.",
+      scaleCalibration: "Калибровка весов",
+      currentWeight: "Текущая масса",
+      tareOffset: "Тара",
+      calFactor: "Коэффициент",
+      knownWeight: "Эталонная масса",
+      calStep1: "Уберите всё с весов и нажмите «Установить ноль».",
+      calStep2: "Положите на весы груз известной массы.",
+      setZero: "Установить ноль",
+      calibrateNow: "Калибровать",
+      calibrated: "Откалибровано",
+      tareSet: "Команда тарирования отправлена. Ожидание устройства...",
+      tareComplete: "Тарирование завершено!",
+      tareTimedOut: "Истекло время ожидания тарирования — демон SpoolBuddy запущен?",
+      tareFailed: "Не удалось отправить команду тарирования",
+      zeroSet: "Нулевая точка установлена. Положите на весы груз известной массы.",
+      calibrationDone: "Калибровка завершена!",
+      calibrationFailed: "Ошибка калибровки",
+      lastCalibrated: "Последняя калибровка",
+      stable: "Стабильно",
+      settling: "Стабилизация...",
+      firmware: "Прошивка",
+      scale: "Весы",
+      noDevice: "Устройство SpoolBuddy не найдено",
+      daemonVersion: "Версия демона",
+      currentVersion: "Текущая",
+      versionPending: "Ожидание демона...",
+      checking: "Проверка...",
+      checkUpdates: "Проверить обновления",
+      updateAvailable: "Доступно обновление",
+      updateInstructions: "Обновление по SSH: запустите установочный скрипт SpoolBuddy.",
+      upToDate: "Установлена актуальная версия",
+      includeBeta: "Включать бета-версии",
+    },
+    writeTag: {
+      tabExisting: "Существующая катушка",
+      tabNew: "Новая катушка",
+      tabReplace: "Заменить метку",
+      searchPlaceholder: "Поиск по материалу, цвету или бренду...",
+      noUntaggedSpools: "Нет катушек без меток",
+      noTaggedSpools: "Нет катушек с метками",
+      selectSpool: "Выберите катушку, затем положите чистую метку NTAG на считыватель",
+      placeTag: "Положите метку NTAG на считыватель",
+      tagReady: "Метка обнаружена — можно записывать",
+      writeTag: "Записать метку",
+      replaceTag: "Заменить метку",
+      writing: "Запись метки...",
+      waiting: "Ожидание SpoolBuddy...",
+      writeSuccess: "Метка успешно записана!",
+      writeFailed: "Ошибка записи",
+      queueFailed: "Не удалось поставить команду записи в очередь",
+      tryAgain: "Повторить",
+      cancel: "Отмена",
+      replaceWarning: "Старая метка будет отвязана и заменена новой.",
+      deviceOffline: "SpoolBuddy не в сети",
+      material: "Материал",
+      colorName: "Название цвета",
+      color: "Цвет",
+      brand: "Бренд",
+      weight: "Масса (г)",
+      createSpool: "Создать катушку",
+      creating: "Создание...",
+      spoolCreated: "Катушка создана! Можно записывать метку.",
+      createFailed: "Не удалось создать катушку",
+      incompleteDataWarning: "Метка записана с неполными данными Spoolman",
+    },
+    quickMenu: {
+      printerPower: "Питание принтера",
+      systemControls: "Система",
+      restartDaemon: "Перезапустить демон",
+      restartBrowser: "Перезапустить браузер",
+      reboot: "Перезагрузить",
+      shutdown: "Выключить",
+      swipeToClose: "Проведите вниз, чтобы закрыть",
+      confirmTitle: "Подтверждение",
+      confirmShutdown: "Выключить SpoolBuddy? Для повторного включения потребуется физический доступ.",
+      confirmReboot: "Перезагрузить SpoolBuddy?",
+      confirmRestartDaemon: "Перезапустить демон SpoolBuddy? NFC и весы временно будут недоступны.",
+      confirmRestartBrowser: "Перезапустить браузер киоска? Экран ненадолго погаснет.",
+      confirm: "Подтвердить",
+      confirmPlugOn: "Включить {{name}}?",
+      confirmPlugOff: "Выключить {{name}}?",
+      turnOn: "Включить",
+      turnOff: "Выключить",
+    },
+  },
+  diagnostic: {
+    modalTitle: "Диагностика подключения — {{name}}",
+    running: "Выполняется диагностика...",
+    runningElapsed: "Выполняется диагностика... ({{elapsed}} с)",
+    waitingForReportHint: "Ожидание публикации отчёта о состоянии принтера — это может занять до {{max}} секунд.",
+    runFailed: "Не удалось выполнить диагностику: {{error}}",
+    retry: "Запустить снова",
+    runButton: "Запустить диагностику",
+    sectionTitle: "Диагностика подключения",
+    sectionDescription: "Проверьте, почему принтер не подключается или не печатает: доступность портов, режим разработчика LAN, сетевой режим Docker и учётные данные.",
+    noPrinters: "Принтеры не настроены.",
+    overall: {
+      ok: "Проблем не обнаружено — подключение к принтеру исправно.",
+      warnings: "Принтер должен работать, но некоторые пункты требуют внимания.",
+      problems: "Обнаружены проблемы, объясняющие, почему принтер не подключается или не печатает.",
+    },
+    check: {
+      port_mqtt: {
+        title: "Порт управления (MQTT 8883)",
+        pass: "Доступен — принтер принимает управляющие подключения.",
+        fail: "Порт 8883 недоступен. Принтер выключен, имеет другой IP-адрес либо подключение блокирует межсетевой экран. Проверьте IP-адрес принтера и доступность порта 8883.",
+      },
+      port_ftps: {
+        title: "Порт передачи файлов (FTPS 990)",
+        pass: "Доступен — отправка файлов печати будет работать.",
+        warn: "Порт 990 недоступен. Мониторинг может работать, но отправка заданий на принтер завершится ошибкой. Убедитесь, что порт 990 не заблокирован.",
+      },
+      external_storage: {
+        title: "Сохранять отправленные файлы во внешнем хранилище (шаг 4 установки)",
+        pass: "Принтер сообщает, что параметр включён: отправленные файлы будут сохраняться на SD-карте, а в архиве появятся миниатюры и метаданные слайсера.",
+        fail: "Принтер сообщает, что параметр выключен. Включите «Сохранять отправленные файлы во внешнем хранилище»: в новых прошивках (P2S 01.02 / Bambu Studio 2.6+) переключатель находится в настройках печати принтера, в старых — на вкладке «Устройство» Bambu Studio / OrcaSlicer. Без этого у всех архивных заданий не будет миниатюр и метаданных слайсера.",
+        skip: "Не проверено — требуется активное подключение MQTT. В старых версиях слайсеров этот параметр существует только в слайсере и принтер его не сообщает, поэтому проверка может пройти даже при выключенном параметре. Проверьте шаг 4 установки вручную.",
+        skip_unsupported_model: "В этой модели есть слот SD-карты, но включить соответствующую функцию невозможно: текущая прошивка серии P1 не предоставляет переключатель в Bambu Studio, а у принтера нет экрана. Исправлять здесь нечего. Пока Bambu Lab не добавит поддержку в прошивку, архивные задания могут сохраняться без миниатюр и метаданных слайсера.",
+      },
+      port_rtsps: {
+        title: "Порт камеры ({{protocol}} {{port}})",
+        pass: "Доступен — видеопоток камеры будет работать.",
+        warn: "Порт {{port}} недоступен. Просмотр камеры в реальном времени работать не будет. На печать это не влияет.",
+      },
+      network_mode: {
+        title: "Сетевой режим Docker",
+        pass: "Используется сетевой режим host.",
+        warn: "Bambuddy запущен в Docker с сетью bridge. Для обнаружения принтеров и виртуального принтера требуется режим host — пересоздайте контейнер с параметром «network_mode: host».",
+        skip: "Bambuddy запущен не в Docker — проверка неприменима.",
+      },
+      subnet: {
+        title: "Подсеть",
+        pass: "Принтер и Bambuddy находятся в одной подсети.",
+        warn: "Принтер ({{printer_ip}}) и Bambuddy ({{host_ip}}) находятся в разных подсетях. Без настроенной маршрутизации между подсетями они могут быть недоступны друг для друга.",
+        skip: "Не удалось определить подсеть — проверка пропущена.",
+      },
+      mqtt_auth: {
+        title: "Учётные данные принтера",
+        pass: "Принтер принял подключение.",
+        fail: "Принтер доступен, но отклонил подключение. Скорее всего, неверны код доступа или серийный номер. Код доступа изменяется при каждом переключении режима разработчика — снова скопируйте его с экрана принтера.",
+        skip: "Не проверено — принтер недоступен.",
+      },
+      developer_mode: {
+        title: "Режим разработчика LAN",
+        pass: "Режим разработчика включён.",
+        fail: "Режим разработчика на принтере ВЫКЛЮЧЕН. Включите его в настройках LAN принтера и подтвердите кнопкой OK. Без него задания печати не запускаются.",
+        skip: "Не удалось проверить — требуется активное подключение к принтеру.",
+      },
+      printer_publishing: {
+        title: "Принтер публикует состояние",
+        pass: "Принтер публикует обновления состояния — AMS, филаменты и K-профили будут корректно передаваться в слайсер.",
+        fail: "MQTT-брокер принял подключение, но принтер не опубликовал ни одного отчёта о состоянии. Почти всегда причина в неверном регистре или ошибке в серийном номере: тема device/<serial>/report чувствительна к регистру. Сверьте серийный номер в настройках принтера с номером на его экране.",
+        skip: "Не удалось проверить — требуется активное подключение к принтеру.",
+      },
+    },
+  },
+  systemHealth: {
+    sectionTitle: "Состояние системы",
+    sectionDescription: "Проверяет последние журналы на известные проблемы, которые обычно можно исправить самостоятельно до обращения в поддержку.",
+    rescan: "Проверить снова",
+    clean: "В последних {{times}} записях журнала известные проблемы не найдены.",
+    logUnavailable: "Запись журналов в файл отключена, поэтому выполнить проверку невозможно. Включите файловое журналирование.",
+    learnMore: "Как исправить",
+    fixLabel: "Исправление:",
+    occurrences: "Обнаружено {{times}} раз; последний раз — {{lastSeen}}",
+    category: {
+      layer8: "Можно исправить самостоятельно",
+      environment: "Окружение",
+      bug: "Сообщите об ошибке",
+    },
+    signature: {
+      "ftp-auth-rejected": {
+        name: "Принтер отклонил код доступа",
+        cause: "Принтер отклонил вход в службу передачи файлов. Код доступа неверен либо изменился после переключения режима разработчика.",
+        fix: "Снова скопируйте код доступа с экрана принтера в настройках LAN и обновите его в настройках принтера Bambuddy.",
+      },
+      "ftp-connection-timeout": {
+        name: "Истекло время подключения к службе передачи файлов",
+        cause: "Bambuddy не смог подключиться к порту передачи файлов принтера (FTPS 990). Порт заблокирован, принтер выключен либо находится в другой подсети.",
+        fix: "Убедитесь, что порт 990 между Bambuddy и принтером не блокируется и оба устройства находятся в доступных друг другу сетях.",
+      },
+      "ftp-ssl-error": {
+        name: "Ошибка защищённого соединения с файловой службой",
+        cause: "Не удалось выполнить TLS-рукопожатие с сервером передачи файлов принтера. Частые причины — межсетевой экран или устаревшая прошивка принтера.",
+        fix: "Обновите прошивку принтера и убедитесь, что межсетевой экран или прокси не перехватывает соединение на порту 990.",
+      },
+      "mqtt-connection-flapping": {
+        name: "Подключение к принтеру постоянно обрывается",
+        cause: "Управляющее соединение MQTT 8883 постоянно разрывается и восстанавливается. Обычно причина в нестабильной сети или частично заблокированном порте.",
+        fix: "Проверьте уровень Wi‑Fi у принтера, по возможности используйте проводное подключение и убедитесь в стабильной доступности порта 8883.",
+      },
+      "camera-connection-refused": {
+        name: "Видеопоток камеры недоступен",
+        cause: "Камера недоступна по порту RTSPS 322. Порт заблокирован либо на принтере выключены камера или просмотр по LAN.",
+        fix: "Включите камеру и просмотр по LAN на принтере и убедитесь, что порт 322 не заблокирован. На печать это не влияет.",
+      },
+      "database-locked": {
+        name: "Конфликт записи в базу данных",
+        cause: "Под нагрузкой SQLite выдаёт ошибки «database is locked». Это часто происходит при одновременной работе с несколькими принтерами.",
+        fix: "Переведите Bambuddy на внешнюю базу PostgreSQL. См. руководство по PostgreSQL в документации.",
+      },
+    },
+  },
+  vpDiagnostic: {
+    title: "Проверка настройки — {{name}}",
+    runButton: "Запустить проверку",
+    running: "Выполняется проверка настройки...",
+    runFailed: "Не удалось выполнить проверку: {{error}}",
+    retry: "Запустить снова",
+    overall: {
+      ok: "Все проверки пройдены — виртуальный принтер настроен правильно.",
+      warnings: "Виртуальный принтер должен работать, но некоторые пункты требуют внимания.",
+      problems: "Обнаружены проблемы, из-за которых слайсер не видит или не может использовать виртуальный принтер.",
+    },
+    check: {
+      enabled: {
+        title: "Виртуальный принтер включён",
+        fail: "Виртуальный принтер выключен. Включите его, чтобы он обнаруживался в сети.",
+      },
+      running: {
+        title: "Службы запущены",
+        fail: "Виртуальный принтер включён, но его службы не работают. Проверьте журнал Bambuddy: обычно запуску мешает конфликт IP-адреса привязки или ошибка прав доступа.",
+      },
+      bind_interface: {
+        title: "Сетевой интерфейс привязки",
+        fail: "Интерфейс привязки не задан или больше не существует на этом хосте. Выберите актуальный интерфейс в списке «Интерфейс привязки».",
+      },
+      access_code: {
+        title: "Код доступа задан",
+        fail: "Код доступа не задан. В слайсере необходимо указать тот же восьмизначный код, который задан здесь.",
+      },
+      target_printer: {
+        title: "Целевой принтер",
+        fail: "Целевой принтер не выбран. Для режима прокси необходимо указать реальный принтер.",
+        warn: "Целевой принтер сейчас не в сети. Перенаправление возобновится после его подключения.",
+      },
+      port_ftps: {
+        title: "Служба загрузки файлов (порт {{port}})",
+        fail: "На порту {{port}} выбранного IP-адреса никто не слушает, поэтому слайсер не сможет загружать файлы. Обычно причина в конфликте порта на этом интерфейсе.",
+      },
+      port_mqtt: {
+        title: "Служба управления (порт {{port}})",
+        fail: "На порту {{port}} выбранного IP-адреса никто не слушает, поэтому слайсер не сможет подключиться и отображать состояние.",
+      },
+      port_bind: {
+        title: "Служба обнаружения (порт {{port}})",
+        fail: "На порту {{port}} выбранного IP-адреса никто не слушает, поэтому сетевое обнаружение слайсером не работает.",
+      },
+      certificate: {
+        title: "Сертификат TLS",
+        pass: "Сертификат готов. Убедитесь, что сертификат центра сертификации Bambuddy, указанный выше, импортирован в доверенное хранилище слайсера.",
+        fail: "Сертификат TLS для этого виртуального принтера отсутствует. Проверьте права записи в каталог данных Bambuddy.",
+      },
+    },
+  },
+  bugReport: {
+    title: "Сообщить об ошибке",
+    description: "Описание",
+    descriptionPlaceholder: "Что пошло не так? Опишите проблему...",
+    email: "Электронная почта (необязательно)",
+    emailPlaceholder: "your@email.com",
+    emailPrivacy: "Указанный адрес будет добавлен в сворачиваемый раздел задачи GitHub, чтобы сопровождающий мог с вами связаться.",
+    screenshot: "Снимок экрана",
+    uploadOrPaste: "Загрузите, вставьте или перетащите изображение",
+    dataCollectedSummary: "Какие данные включаются в отчёт?",
+    dataIncluded: "Включаются:",
+    dataIncludedList: "Версия приложения, ОС, архитектура, версия Python, статистика базы данных (только количества), модели принтеров, количество сопел, версии прошивок, состояние подключений, состояние интеграций (Spoolman, MQTT, HA), неконфиденциальные настройки, количество сетевых интерфейсов, сведения о Docker и версии зависимостей.",
+    dataNeverIncluded: "Никогда не включаются:",
+    dataNeverIncludedList: "Имена принтеров, серийные номера, коды доступа, пароли, IP-адреса, адреса электронной почты, ключи API, токены, URL вебхуков, имена хостов и пользователей.",
+    submit: "Отправить",
+    startLogging: "Начать отладочную запись",
+    stepEnableLogging: "Отладочная запись включена",
+    stepReproduce: "Теперь воспроизведите проблему",
+    stepStopLogging: "Остановить и отправить отчёт",
+    stopAndSubmit: "Остановить и отправить",
+    maxDuration: "Автоматическая остановка через {{minutes}} мин",
+    stoppingLogs: "Сбор журналов и отправка...",
+    submitting: "Отправка отчёта об ошибке...",
+    submittingStepConnection: "Проверка подключений к принтерам",
+    submittingStepVirtualPrinters: "Проверка настройки виртуальных принтеров",
+    submittingStepLogScan: "Поиск известных проблем в последних журналах",
+    submittingStepSubmit: "Отправка отчёта в GitHub",
+    submitSuccess: "Отчёт об ошибке успешно отправлен!",
+    submitFailed: "Не удалось отправить отчёт об ошибке",
+    diagnosticChecking: "Проверка подключений к принтерам...",
+    diagnosticHealthy: "Проверка подключения пройдена — проблем с принтерами не обнаружено.",
+    diagnosticSummary: "Проблемы с подключением обнаружены у {{problems}} из {{total}} принтеров",
+    diagnosticIntro: "У одного или нескольких принтеров есть проблема подключения, которая может быть причиной сбоя. Разверните сведения о принтере ниже и посмотрите способ исправления — возможно, отчёт об ошибке не потребуется. При необходимости его всё равно можно отправить ниже.",
+    logHealthSummary: "В журналах найдены известные проблемы",
+    logHealthIntro: "Последние записи журнала совпадают с известными проблемами. Посмотрите способы исправления ниже — возможно, отчёт об ошибке не потребуется. При необходимости его всё равно можно отправить.",
+    thankYou: "Спасибо!",
+    submitted: "Отчёт об ошибке отправлен.",
+    viewIssue: "Открыть задачу",
+    unexpectedError: "Произошла непредвиденная ошибка",
+  },
+  failureDetection: {
+    title: "Обнаружение сбоев с помощью ИИ",
+    description: "Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.",
+    mlUrl: "URL Obico ML API",
+    mlUrlHint: "Базовый URL собственного контейнера Obico ml_api, например http://192.168.1.10:3333.",
+    test: "Проверить",
+    testSuccess: "ML API доступен и работает.",
+    testFailed: "Не удалось подключиться к ML API.",
+    sensitivity: "Чувствительность",
+    sensitivityLow: "Низкая (меньше ложных срабатываний)",
+    sensitivityMedium: "Средняя (сбалансированная)",
+    sensitivityHigh: "Высокая (раннее обнаружение, больше ложных срабатываний)",
+    sensitivityHint: "Определяет пороги уверенности, при которых создаются предупреждения и фиксируются сбои.",
+    action: "Действие при обнаружении сбоя",
+    actionNotify: "Только уведомить",
+    actionPause: "Приостановить печать",
+    actionPauseOff: "Приостановить печать и отключить питание",
+    pollInterval: "Интервал проверки (секунды)",
+    pollIntervalHint: "Как часто проверять каждый принтер во время печати. Минимум 5 с, максимум 120 с.",
+    externalUrlMissing: "Внешний URL не задан.",
+    externalUrlHint: "ML API получает снимок камеры по URL. Укажите внешний URL в общих настройках, чтобы контейнер ML API мог обращаться к Bambuddy.",
+    perPrinterTitle: "Контролируемые принтеры",
+    perPrinterHint: "Выберите принтеры, за которыми будет следить служба обнаружения.",
+    monitorAll: "Контролировать все подключённые принтеры",
+    statusTitle: "Состояние",
+    serviceRunning: "Служба работает",
+    thresholds: "Низкий / высокий порог",
+    activePrinters: "Активные задания",
+    noActivePrints: "Сейчас ничего не печатается.",
+    historyTitle: "Последние обнаружения",
+    noHistory: "Обнаружений пока нет.",
+  },
+  makerworld: {
+    title: "MakerWorld",
+    description: "Вставьте URL модели MakerWorld, чтобы импортировать и напечатать её прямо из Bambuddy — без перехода в приложение Bambu Handy.",
+    pasteUrlHeader: "Импорт из MakerWorld",
+    pasteUrlPlaceholder: "https://makerworld.com/en/models/… или любая ссылка MakerWorld",
+    resolveButton: "Получить данные",
+    signInRequiredTitle: "Для загрузки требуется вход в Bambu Cloud",
+    signInRequiredBody: "Просматривать сведения о модели можно анонимно, но для загрузки файлов 3MF MakerWorld требует учётную запись Bambu Cloud.",
+    signInExpiredTitle: "Срок авторизации в Bambu Cloud истёк",
+    signInExpiredBody: "Вы по-прежнему авторизованы в Bambuddy, но Bambu Lab перестал принимать сохранённый токен, поэтому загрузки завершатся ошибкой. Выполните вход в Bambu Cloud снова.",
+    openCloudSettings: "Открыть настройки облака",
+    untitledModel: "Модель без названия",
+    byCreator: "Автор: {{name}}",
+    downloadsCount: "Загрузок: {{count}}",
+    licensePrefix: "Лицензия",
+    alreadyImported: "Уже в библиотеке",
+    openOnMakerworld: "Открыть на MakerWorld",
+    alreadyInLibrary: "Эта модель уже есть в библиотеке — найдите её в разделе «Файлы → MakerWorld».",
+    importSuccess: "Импортирован файл {{filename}} — сохранён в «Файлы → MakerWorld».",
+    platesHeader: "Пластины ({{count}})",
+    plateDefaultName: "Пластина {{n}}",
+    materialCount: "Филаментов: {{count}}",
+    amsRequired: "Требуется AMS",
+    slicedFor: "Нарезано для {{printer}}",
+    alsoCompatible: "Также отмечено как совместимое: {{printers}}",
+    importToLibrary: "Сохранить",
+    sliceIn: "Сохранить и нарезать в {{slicer}}",
+    disclaimer: "Интеграция с MakerWorld использует документированные сообществом конечные точки API. Bambuddy не связан с MakerWorld или Bambu Lab и не одобрен ими.",
+    lastImportSuccess: "Импортировано в библиотеку",
+    lastImportAlreadyInLibrary: "Уже в библиотеке",
+    viewInLibrary: "Открыть в файловом менеджере",
+    openInBambuStudio: "Открыть в Bambu Studio",
+    openInOrcaSlicer: "Открыть в OrcaSlicer",
+    importTo: "Импортировать в файловый менеджер",
+    recentImportsHeader: "Недавние импорты",
+    phaseResolving: "Получение данных",
+    phaseDownloading: "Загрузка",
+    folderAuto: "MakerWorld (по умолчанию)",
+    importAll: "Импортировать всё",
+    importAllProgress: "Импорт {{current}} из {{total}}",
+    openGallery: "Открыть галерею изображений",
+    galleryPrev: "Предыдущее изображение",
+    galleryNext: "Следующее изображение",
+    deleteImport: "Удалить из библиотеки",
+    importDeleting: "Удаление…",
+    importDeleted: "Удалено из библиотеки",
+    confirmDelete: "Удалить {{filename}} из библиотеки? Локальный файл будет удалён, но пластину можно снова импортировать из MakerWorld.",
+    errors: {
+      resolveFailed: "Не удалось обработать эту ссылку MakerWorld.",
+      downloadFailed: "Ошибка загрузки. Повторите попытку.",
+      deleteFailed: "Не удалось удалить файл из библиотеки.",
+    },
+  },
+  gcodeViewer: {
+    back: "Назад",
+    backToArchives: "Вернуться в архив печати",
+    backToFiles: "Вернуться в файловый менеджер",
+  },
+  libraryTrash: {
+    title: "Корзина",
+    headerButton: "Корзина",
+    headerTooltip: "Просмотреть файлы, перемещённые в корзину",
+    backToFiles: "Вернуться к файлам",
+    subtitleAdmin: "Удалённые файлы хранятся здесь {{days}} дней, затем удаляются автоматически. Показаны файлы всех пользователей.",
+    subtitleUser: "Удалённые файлы хранятся здесь {{days}} дней, затем удаляются автоматически.",
+    loading: "Загрузка корзины…",
+    loadError: "Не удалось загрузить корзину.",
+    empty: "Корзина пуста.",
+    summary: "{{count}} файлов · {{size}}",
+    emptyTrash: "Очистить корзину",
+    restore: "Восстановить",
+    purgeNow: "Удалить сейчас",
+    autoPurgeIn: "Автоудаление через {{when}}",
+    days: "дней",
+    retentionLabel: "Автоудаление через",
+    selectAll: "Выбрать всё",
+    selectOne: "Выбрать {{filename}}",
+    selectionCount: "Выбрано: {{count}}",
+    bulkRestore: "Восстановить выбранные",
+    bulkPurge: "Удалить выбранные",
+    col: {
+      filename: "Файл",
+      folder: "Папка",
+      size: "Размер",
+      deleted: "Перемещён в корзину",
+      autoPurge: "Автоудаление",
+      owner: "Владелец",
+      actions: "Действия",
+    },
+    confirm: {
+      purgeTitle: "Удалить навсегда?",
+      purgeBody: "Файл {{filename}} будет удалён с диска без возможности восстановления.",
+      emptyTitle: "Очистить корзину?",
+      emptyBody: "Все файлы ({{count}}) будут удалены с диска. Отменить это действие нельзя.",
+      bulkPurgeTitle: "Удалить выбранные файлы навсегда?",
+      bulkPurgeBody: "Выбранные файлы ({{count}}) будут удалены с диска без возможности восстановления.",
+      cta: "Удалить навсегда",
+    },
+    toast: {
+      restored: "Файл восстановлен.",
+      restoreFailed: "Не удалось восстановить файл.",
+      purged: "Файл удалён навсегда.",
+      purgeFailed: "Не удалось удалить файл.",
+      emptied: "Из корзины удалено файлов: {{count}}.",
+      emptyFailed: "Не удалось очистить корзину.",
+      retentionSaved: "Срок автоудаления: {{days}} дней.",
+      retentionFailed: "Не удалось сохранить срок хранения.",
+      bulkRestored: "Восстановлено файлов: {{count}}.",
+      bulkPurged: "Удалено файлов: {{count}}.",
+    },
+  },
+  libraryPurge: {
+    title: "Очистить старые файлы",
+    headerButton: "Очистить старые",
+    headerTooltip: "Массово переместить старые файлы в корзину",
+    description: "За один проход убирает старые файлы из библиотеки. Для печатавшихся файлов возраст считается от даты последней печати, для ни разу не печатавшихся — от даты загрузки.",
+    ageLabel: "Переместить файлы старше",
+    days: "дней",
+    includeNeverPrinted: "Включить файлы, которые ни разу не печатались",
+    effectsTitle: "Что произойдёт после нажатия «Очистить»",
+    effect1: "Подходящие файлы будут перемещены в корзину, но пока не удалены с диска.",
+    effect2: "Их можно восстановить из корзины до окончания срока хранения.",
+    effect3: "После окончания срока хранения очистка корзины окончательно удалит их с диска.",
+    effect4: "Файлы во внешних подключённых папках пропускаются: Bambuddy не удаляет чужие данные.",
+    previewLoading: "Подсчёт подходящих файлов…",
+    previewFailed: "Не удалось выполнить предварительный расчёт.",
+    previewSummary: "В корзину будет перемещено: {{count}} файлов · {{size}}",
+    andMore: "…и ещё {{count}}",
+    warning: "Файлы в корзине продолжают занимать место до окончания срока хранения. Чтобы освободить диск сразу, после операции очистите корзину.",
+    confirmCta: "Переместить в корзину: {{count}}",
+    purging: "Перемещение в корзину…",
+    toast: {
+      success: "Перемещено в корзину файлов: {{count}}.",
+      failed: "Не удалось очистить файлы.",
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: "Автоматически очищать старые файлы",
+    enableDescription: "Раз в сутки запускает административную очистку. Сначала файлы попадают в корзину и не удаляются немедленно.",
+    ageLabel: "Автоматически очищать файлы старше",
+    ageDescription: "Минимум 7 дней, максимум 10 лет. Используется тот же способ расчёта возраста, что и при ручной очистке.",
+    days: "дней",
+    includeNeverPrinted: "Включить файлы, которые ни разу не печатались",
+    saveFailed: "Не удалось сохранить настройки автоочистки.",
+  },
+  archivePurge: {
+    headerButton: "Очистить старые",
+    headerTooltip: "Массово удалить старые записи архива",
+    title: "Очистить старые записи архива",
+    description: "Удаляет старую историю печати. Возраст записи считается от последнего завершения печати; повторная печать обновляет дату, поэтому используемые записи не удаляются.",
+    ageLabel: "Удалить записи, которые не печатались последние",
+    days: "дней",
+    effectsTitle: "Что произойдёт после нажатия «Очистить»",
+    effect1: "Каждая подходящая запись будет скрыта из списков, а связанные файлы удалены с диска: 3MF, миниатюра, таймлапс, исходный 3MF, проект F3D и фотографии.",
+    effect2: "Строка архива останется в базе, поэтому быстрая статистика сохранит вклад по филаменту, времени, стоимости и энергии — как при обычном удалении отдельной записи.",
+    effect3: "Установите флажок «Также удалить из статистики», чтобы исключить вклад из быстрой статистики. Это действие необратимо.",
+    effect4: "Повторная печать обновляет дату записи, поэтому используемые архивы не удаляются.",
+    purgeStatsLabel: "Также удалить из статистики",
+    purgeStatsHint: "Исключает соответствующие записи из быстрой статистики по филаменту, времени, стоимости и энергии. Без этого удаляются только файлы, а статистика сохраняется.",
+    previewLoading: "Подсчёт подходящих записей…",
+    previewFailed: "Не удалось выполнить предварительный расчёт.",
+    previewSummary: "Будет удалено: {{count}} записей · {{size}}",
+    andMore: "…и ещё {{count}}",
+    warning: "Файлы будут удалены с диска без возможности восстановления. Перед продолжением скачайте или добавьте в избранное всё, что нужно сохранить.",
+    confirmCta: "Удалить записей: {{count}}",
+    purging: "Удаление…",
+    toast: {
+      success: "Удалено записей архива: {{count}}.",
+      failed: "Не удалось очистить архив.",
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: "Автоматически очищать старые записи архива",
+    enableDescription: "Раз в сутки скрывает записи из списков и удаляет их файлы с диска, если они не печатались в течение заданного срока. Повторная печать сбрасывает отсчёт.",
+    ageLabel: "Автоматически удалять записи, которые не печатались последние",
+    ageDescription: "Минимум 7 дней, максимум 10 лет. Срок считается от последнего завершения печати; повторная печать обновляет дату. Удаляются 3MF, миниатюра, таймлапс, исходный 3MF, F3D и фотографии.",
+    days: "дней",
+    purgeStatsLabel: "Также удалить из статистики",
+    purgeStatsDescription: "Если включено, ежедневная очистка исключает удалённые записи из быстрой статистики по филаменту, времени, стоимости и энергии. По умолчанию выключено: статистика сохраняется, удаляются только файлы.",
+    runNow: "Очистить архив сейчас",
+    saveFailed: "Не удалось сохранить настройки автоочистки.",
+  },
+  cameraTokens: {
+    scope: {
+      camera_stream: "Поток камеры",
+      camwall: "Стена камер",
+      overlay: "Оверлей для стрима",
+    },
+    title: "Токены API камеры",
+    navTitle: "Токены API камеры",
+    description: "Долгоживущие токены для встраивания видеопотока в Home Assistant, Frigate, киоски и другие системы, которым нужен постоянный URL. Каждый токен даёт доступ только к потоку камеры и может быть отозван в любой момент.",
+    loading: "Загрузка...",
+    confirmRevoke: {
+      title: "Отозвать этот токен?",
+      body: "Все устройства, использующие «{{name}}», немедленно потеряют доступ. Отменить это действие нельзя.",
+      cancel: "Отмена",
+      confirm: "Отозвать",
+    },
+    create: {
+      scopeLabel: "Область действия",
+      hintCameraStream: "Токен потока камеры позволяет получать только видеопотоки и снимки. Используйте его для Home Assistant, Frigate и других систем, встраивающих изображение с одной камеры.",
+      hintCamWall: "Токен стены камер открывает страницу /camwall на экране без авторизации. Он позволяет видеть названия и состояния всех принтеров, а также видеопотоки с их камер. Имена файлов, адреса и коды доступа недоступны.",
+      hintOverlay: "Токен оверлея для стрима открывает страницу /overlay/{printerId} на экране без авторизации — для OBS или любой прямой трансляции. Он даёт доступ к видеопотоку одного принтера и его текущему статусу печати, включая имя файла, отображаемое на экране. Адреса и коды доступа недоступны.",
+      title: "Создать токен",
+      nameLabel: "Название токена",
+      namePlaceholder: "Например, Home Assistant",
+      daysLabel: "Срок действия, дней",
+      submit: "Создать",
+      hint: "Максимальный срок действия — 365 дней. Значение токена показывается только один раз при создании — скопируйте его сейчас.",
+    },
+    created: {
+      camWallUrlTitle: "URL стены камер для этого экрана",
+      camWallUrlHint: "Откройте этот адрес на экране. Любой, кто получит URL, сможет наблюдать за стеной камер, поэтому обращайтесь с ним как с ключом. Отзовите токен, чтобы отключить экран.",
+      overlayUrlTitle: "URL оверлея для OBS",
+      overlayUrlHint: "Добавьте этот адрес как источник браузера (Browser Source) в OBS. Замените число в /overlay/1 на номер вашего принтера (его можно узнать из URL на странице «Принтеры»). Любой, кто получит URL, сможет смотреть трансляцию, поэтому обращайтесь с ним как с ключом — отзовите токен, чтобы прекратить доступ.",
+      title: "Токен создан — скопируйте его сейчас",
+      warning: "Это единственный раз, когда токен отображается. После закрытия окна посмотреть его снова будет невозможно.",
+      copy: "Копировать",
+      dismiss: "Я сохранил токен",
+    },
+    list: {
+      scope: "Область действия",
+      myTitle: "Мои токены",
+      allTitle: "Все пользователи (режим администратора)",
+      empty: "Токенов пока нет.",
+      name: "Название",
+      owner: "Владелец",
+      prefix: "Префикс",
+      created: "Создан",
+      expires: "Истекает",
+      lastUsed: "Последнее использование",
+      revoke: "Отозвать",
+      expired: "Истёк",
+    },
+    toast: {
+      created: "Токен создан",
+      createFailed: "Не удалось создать токен",
+      revoked: "Токен отозван",
+      revokeFailed: "Не удалось отозвать токен",
+      loadFailed: "Не удалось загрузить токены",
+      copied: "Скопировано в буфер обмена",
+      copyFailed: "Не удалось скопировать — выделите и скопируйте вручную",
+    },
+  },
+  forecast: {
+    title: "Прогноз",
+    noSpools: "Активные катушки не найдены. Добавьте катушки в учёт, чтобы увидеть прогноз.",
+    noUsageData: "Нет данных о расходе — невозможно спрогнозировать остатки.",
+    sku: "Артикул",
+    material: "Материал",
+    stock: "Запас",
+    dailyRate: "Расход",
+    daysLeft: "Осталось дней",
+    emptyBy: "Закончится к",
+    reorderBy: "Заказать до",
+    actions: "Действия",
+    trend: "Тренд",
+    estimated: "Оценка",
+    noData: "Нет данных",
+    timeframe: "Период",
+    chartTitle: "Прогноз запаса — 5 основных материалов",
+    dashedLinesROP: "Пунктирные линии — точки заказа",
+    stockLevel: "Уровень запаса",
+    reorderPoint: "Точка заказа",
+    safetyMargin: "Страховой запас",
+    trendLegend: "Тренд (по истории, уровень обслуживания 95%)",
+    estimatedLegend: "Оценка (по изменению массы)",
+    noDataLegend: "Нет данных",
+    ropLabel: "ТЗ",
+    ssLabel: "СЗ",
+    safetyStockLegend: "Страховой запас",
+    stockArrivalLegend: "Поступление",
+    stockoutLegend: "Исчерпание запаса",
+    alertCount_one: "{{count}} предупреждение",
+    alertCount_other: "{{count}} предупреждений",
+    order: "Заказать",
+    save: "Сохранить",
+    cancel: "Отмена",
+    settingsSaved: "Настройки сохранены",
+    failedSaveSettings: "Не удалось сохранить настройки",
+    globalLeadTimeSaved: "Общий срок поставки сохранён",
+    globalLeadTime: "Общий срок поставки",
+    globalLeadTimeHint: "Минимальный общий срок поставки, используемый при расчёте точки заказа для всех артикулов",
+    skuLeadTimeOverride: "Переопределение срока поставки",
+    skuLeadTimeHint: "0 — использовать общий срок поставки. Значение больше 0 переопределяет его для этого артикула.",
+    safetyMarginLabel: "Страховой запас",
+    effectiveLeadTime: "Фактический срок поставки",
+    effectiveLeadTimeHint: "макс. (общий {{global}} дн., для артикула {{sku}} дн.)",
+    reorderPointHint: "средний расход × срок поставки + страховой запас — заказывайте, когда остаток достигнет этого уровня",
+    safetyMarginHint: "Статистический страховой запас (z=1,65 × σ × √LT) плюс заданный пользователем резерв",
+    safetyMarginHintDays: "Резерв поверх статистического страхового запаса.{{approx}}",
+    safetyMarginHintDaysApprox: " ≈ {{g}} г при текущем расходе.",
+    safetyMarginHintG: "Фиксированный весовой резерв поверх статистического страхового запаса.{{approx}}",
+    safetyMarginHintGApprox: " ≈ {{days}} дн. при текущем расходе.",
+    individualSpools: "Отдельные катушки",
+    labelWeight: "Метка",
+    spoolCount_one: "{{count}} катушка",
+    spoolCount_other: "{{count}} катушек",
+    stockBreakRisk: "Риск исчерпания запаса",
+    stockBreakDetail: "Осталось {{days}} дн., срок поставки {{lt}} дн.",
+    stockBreakBefore: "Запас закончится до пополнения",
+    reorderNow: "Заказать сейчас",
+    reorderTriggerPassed: "Дата заказа {{date}} уже прошла.",
+    shoppingList: "Список покупок",
+    shoppingListItems_one: "({{count}} позиция)",
+    shoppingListItems_other: "({{count}} позиций)",
+    shoppingListEmpty: "Список покупок пуст. Нажмите значок корзины в нужной строке, чтобы добавить позицию.",
+    addToCart: "Добавить в список покупок",
+    alertsSnoozed: "Отключить предупреждения для артикула",
+    alertsEnabled: "Включить предупреждения для артикула",
+    addedToCart: "Добавлено в список покупок",
+    failedAddItem: "Не удалось добавить позицию",
+    listView: "Список",
+    logisticsView: "Логистика",
+    qty: "Кол-во",
+    weight: "Масса",
+    leadTime: "Срок поставки",
+    expectedRestock: "Ожидаемое пополнение",
+    status: "Состояние",
+    note: "Примечание",
+    pending: "Ожидается",
+    purchased: "Куплено",
+    received: "Получено",
+    markPurchased: "Отметить как купленное",
+    markReceived: "Отметить как полученное — катушки будут добавлены в запас",
+    resetToPending: "Вернуть в ожидание",
+    remove: "Удалить",
+    clearAll: "Очистить всё",
+    downloadCsv: "CSV",
+    addToCartTitle: "Добавить в список покупок",
+    byQuantity: "По количеству",
+    byDuration: "По сроку",
+    numberOfSpools: "Количество катушек",
+    lastHowManyDays: "На сколько дней должно хватить?",
+    noUsageQty: "Нет данных о расходе — количество установлено равным 1.",
+    noteOptional: "Примечание (необязательно)",
+    notePlaceholder: "например, для проекта X, срочно…",
+    addNSpools_one: "Добавить {{count}} катушку",
+    addNSpools_other: "Добавить {{count}} катушек",
+    onArrival: "После поступления",
+    stockBreakIn: "Запас закончится через {{days}} дн.",
+    stockRunsOutBefore: "Запас закончится раньше, чем истекут {{lt}} дн. срока поставки.",
+    atRate: "При расходе {{rate}} г/день требуется",
+    moreSpools_one: "ещё {{count}} катушка",
+    moreSpools_other: "ещё {{count}} катушек",
+    bridgeGap: "чтобы перекрыть дефицит.",
+    noReadAccess: "У вас нет прав на просмотр прогноза запасов.",
+    noWriteAccess: "У вас нет прав на изменение настроек прогноза.",
+  },
+};

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

@@ -4022,6 +4022,8 @@ export default {
     refreshPresets: 'Yenile',
     refreshPresets: 'Yenile',
     refreshPresetsTitle: "Ön ayarları yenile — en güncel bulut ve paketli listeleri getir (Bambu Studio veya Bambu Handy'de bir ön ayar sildikten sonra kullanın)",
     refreshPresetsTitle: "Ön ayarları yenile — en güncel bulut ve paketli listeleri getir (Bambu Studio veya Bambu Handy'de bir ön ayar sildikten sonra kullanın)",
     allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
     allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
+    useEmbedded: 'Dosyanın yerleşik ayarlarını kullan',
+    useEmbeddedHint: 'Yukarıdaki profiller yerine tasarımcının ayarladığı gibi (duvarlar, dolgu, filament) dilimle. Yazıcınız dosyayla eşleştiği için sunuluyor.',
     enqueuing: 'Dilimleme işi gönderiliyor…',
     enqueuing: 'Dilimleme işi gönderiliyor…',
     queued: 'Kuyrukta…',
     queued: 'Kuyrukta…',
     failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',
     failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',
@@ -6616,6 +6618,7 @@ export default {
     scope: {
     scope: {
       camera_stream: 'Kamera akışı',
       camera_stream: 'Kamera akışı',
       camwall: 'Kamera duvarı',
       camwall: 'Kamera duvarı',
+      overlay: 'Yayın bindirmesi',
     },
     },
     title: 'Kamera API Belirteçleri',
     title: 'Kamera API Belirteçleri',
     navTitle: 'Kamera API belirteçleri',
     navTitle: 'Kamera API belirteçleri',
@@ -6634,6 +6637,8 @@ export default {
         'Kamera akışı belirteci yalnızca kamera akışlarını ve anlık görüntüleri alabilir. Home Assistant, Frigate veya tek bir kamerayı gömen her şey için kullanın.',
         'Kamera akışı belirteci yalnızca kamera akışlarını ve anlık görüntüleri alabilir. Home Assistant, Frigate veya tek bir kamerayı gömen her şey için kullanın.',
       hintCamWall:
       hintCamWall:
         'Kamera duvarı belirteci, oturum açmadan bir ekranda /camwall adresini açar. Her yazıcının adını ve durumunu, ayrıca kamera akışlarını görebilir. Dosya adlarını, adresleri veya erişim kodlarını göremez.',
         'Kamera duvarı belirteci, oturum açmadan bir ekranda /camwall adresini açar. Her yazıcının adını ve durumunu, ayrıca kamera akışlarını görebilir. Dosya adlarını, adresleri veya erişim kodlarını göremez.',
+      hintOverlay:
+        'Yayın bindirmesi belirteci, oturum açmadan bir ekranda /overlay/{printerId} adresini açar — OBS veya herhangi bir canlı yayın için. Bir yazıcının kamera akışını ve ekranda gösterilen dosya adı dahil canlı yazdırma durumunu görebilir. Adresleri veya erişim kodlarını göremez.',
       title: 'Yeni belirteç oluştur',
       title: 'Yeni belirteç oluştur',
       nameLabel: 'Belirteç adı',
       nameLabel: 'Belirteç adı',
       namePlaceholder: 'örn. Home Assistant',
       namePlaceholder: 'örn. Home Assistant',
@@ -6646,6 +6651,9 @@ export default {
       camWallUrlTitle: 'Bu ekran için kamera duvarı adresi',
       camWallUrlTitle: 'Bu ekran için kamera duvarı adresi',
       camWallUrlHint:
       camWallUrlHint:
         'Bu adresi ekranda açın. Adresi okuyabilen herkes duvarı izleyebilir, bu yüzden onu bir anahtar gibi görün; ekranın erişimini kesmek için belirteci iptal edin.',
         'Bu adresi ekranda açın. Adresi okuyabilen herkes duvarı izleyebilir, bu yüzden onu bir anahtar gibi görün; ekranın erişimini kesmek için belirteci iptal edin.',
+      overlayUrlTitle: 'OBS için bindirme adresi',
+      overlayUrlHint:
+        'Bunu OBS\'ye Tarayıcı Kaynağı olarak ekleyin. /overlay/1 içindeki sayıyı yazıcınızın numarasıyla değiştirin (Yazıcılar sayfasındaki adresinden). Adresi okuyabilen herkes yayını izleyebilir, bu yüzden onu bir anahtar gibi görün — erişimi kesmek için belirteci iptal edin.',
       title: 'Belirteç oluşturuldu — şimdi kopyalayın',
       title: 'Belirteç oluşturuldu — şimdi kopyalayın',
       warning:
       warning:
         'Bu, bu belirtecin görünür olacağı tek seferdir. Bu iletişim kutusunu kapattıktan sonra onu bir daha asla görüntüleyemezsiniz.',
         'Bu, bu belirtecin görünür olacağı tek seferdir. Bu iletişim kutusunu kapattıktan sonra onu bir daha asla görüntüleyemezsiniz.',

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

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: '刷新',
     refreshPresets: '刷新',
     refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
     refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
     allPresetsRequired: '必须选择所有预设',
     allPresetsRequired: '必须选择所有预设',
+    useEmbedded: '使用文件的内置设置',
+    useEmbeddedHint: '按设计者的设置(壁、填充、耗材)切片,而非上方的配置文件。因您的打印机与文件匹配而可用。',
     enqueuing: '提交切片任务中…',
     enqueuing: '提交切片任务中…',
     queued: '已排队…',
     queued: '已排队…',
     failed: '切片失败。请检查切片器 sidecar 日志。',
     failed: '切片失败。请检查切片器 sidecar 日志。',
@@ -6662,6 +6664,7 @@ export default {
     scope: {
     scope: {
       camera_stream: '摄像头视频流',
       camera_stream: '摄像头视频流',
       camwall: '摄像头墙',
       camwall: '摄像头墙',
+      overlay: '直播叠加层',
     },
     },
     title: '摄像头 API 令牌',
     title: '摄像头 API 令牌',
     navTitle: '摄像头 API 令牌',
     navTitle: '摄像头 API 令牌',
@@ -6680,6 +6683,8 @@ export default {
         '摄像头视频流令牌只能获取摄像头视频流和快照。适用于 Home Assistant、Frigate 或任何嵌入单个摄像头的场景。',
         '摄像头视频流令牌只能获取摄像头视频流和快照。适用于 Home Assistant、Frigate 或任何嵌入单个摄像头的场景。',
       hintCamWall:
       hintCamWall:
         '摄像头墙令牌可在无需登录的屏幕上打开 /camwall,能看到每台打印机的名称和状态以及摄像头视频流,但看不到文件名、地址或访问码。',
         '摄像头墙令牌可在无需登录的屏幕上打开 /camwall,能看到每台打印机的名称和状态以及摄像头视频流,但看不到文件名、地址或访问码。',
+      hintOverlay:
+        '直播叠加层令牌可在无需登录的屏幕上打开 /overlay/{printerId}——供 OBS 或任何直播使用。它能看到一台打印机的摄像头视频流以及实时打印状态,包括屏幕上显示的文件名,但看不到地址或访问码。',
       title: '创建新令牌',
       title: '创建新令牌',
       nameLabel: '令牌名称',
       nameLabel: '令牌名称',
       namePlaceholder: '例如 Home Assistant',
       namePlaceholder: '例如 Home Assistant',
@@ -6692,6 +6697,9 @@ export default {
       camWallUrlTitle: '此屏幕的摄像头墙网址',
       camWallUrlTitle: '此屏幕的摄像头墙网址',
       camWallUrlHint:
       camWallUrlHint:
         '在屏幕上打开此网址。任何能看到该网址的人都能观看摄像头墙,请像对待钥匙一样对待它——撤销令牌即可切断该屏幕的访问。',
         '在屏幕上打开此网址。任何能看到该网址的人都能观看摄像头墙,请像对待钥匙一样对待它——撤销令牌即可切断该屏幕的访问。',
+      overlayUrlTitle: '用于 OBS 的叠加层网址',
+      overlayUrlHint:
+        '在 OBS 中将其添加为“浏览器”源(Browser Source)。将 /overlay/1 中的数字改为您打印机的编号(可在“打印机”页面的网址中查看)。任何能看到该网址的人都能观看直播,请像对待钥匙一样对待它——撤销令牌即可切断访问。',
       title: '令牌已创建 — 立即复制',
       title: '令牌已创建 — 立即复制',
       warning:
       warning:
         '这是此令牌唯一一次可见。关闭此对话框后您将无法再次查看。',
         '这是此令牌唯一一次可见。关闭此对话框后您将无法再次查看。',

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

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: '重新整理',
     refreshPresets: '重新整理',
     refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
     refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
     allPresetsRequired: '必須選擇所有預設',
     allPresetsRequired: '必須選擇所有預設',
+    useEmbedded: '使用檔案的內建設定',
+    useEmbeddedHint: '依設計者的設定(外牆、填充、耗材)切片,而非上方的設定檔。因您的印表機與檔案相符而可用。',
     enqueuing: '提交切片任務中…',
     enqueuing: '提交切片任務中…',
     queued: '已排隊…',
     queued: '已排隊…',
     failed: '切片失敗。請檢查切片器 sidecar 日誌。',
     failed: '切片失敗。請檢查切片器 sidecar 日誌。',
@@ -6662,6 +6664,7 @@ export default {
     scope: {
     scope: {
       camera_stream: '攝影機串流',
       camera_stream: '攝影機串流',
       camwall: '攝影機牆',
       camwall: '攝影機牆',
+      overlay: '直播疊加層',
     },
     },
     title: '攝影機 API 權杖',
     title: '攝影機 API 權杖',
     navTitle: '攝影機 API 權杖',
     navTitle: '攝影機 API 權杖',
@@ -6680,6 +6683,8 @@ export default {
         '攝影機串流權杖只能取得攝影機串流與快照。適用於 Home Assistant、Frigate 或任何嵌入單一攝影機的情境。',
         '攝影機串流權杖只能取得攝影機串流與快照。適用於 Home Assistant、Frigate 或任何嵌入單一攝影機的情境。',
       hintCamWall:
       hintCamWall:
         '攝影機牆權杖可在無須登入的螢幕上開啟 /camwall,能看到每台印表機的名稱與狀態以及攝影機串流,但看不到檔案名稱、位址或存取碼。',
         '攝影機牆權杖可在無須登入的螢幕上開啟 /camwall,能看到每台印表機的名稱與狀態以及攝影機串流,但看不到檔案名稱、位址或存取碼。',
+      hintOverlay:
+        '直播疊加層權杖可在無須登入的螢幕上開啟 /overlay/{printerId}——供 OBS 或任何直播使用。它能看到一台印表機的攝影機串流以及即時列印狀態,包括螢幕上顯示的檔案名稱,但看不到位址或存取碼。',
       title: '建立新權杖',
       title: '建立新權杖',
       nameLabel: '權杖名稱',
       nameLabel: '權杖名稱',
       namePlaceholder: '例如 Home Assistant',
       namePlaceholder: '例如 Home Assistant',
@@ -6692,6 +6697,9 @@ export default {
       camWallUrlTitle: '此螢幕的攝影機牆網址',
       camWallUrlTitle: '此螢幕的攝影機牆網址',
       camWallUrlHint:
       camWallUrlHint:
         '在螢幕上開啟此網址。任何能看到該網址的人都能觀看攝影機牆,請像對待鑰匙一樣對待它——撤銷權杖即可切斷該螢幕的存取。',
         '在螢幕上開啟此網址。任何能看到該網址的人都能觀看攝影機牆,請像對待鑰匙一樣對待它——撤銷權杖即可切斷該螢幕的存取。',
+      overlayUrlTitle: '用於 OBS 的疊加層網址',
+      overlayUrlHint:
+        '在 OBS 中將其新增為「瀏覽器」來源(Browser Source)。將 /overlay/1 中的數字改為您印表機的編號(可在「印表機」頁面的網址中查看)。任何能看到該網址的人都能觀看直播,請像對待鑰匙一樣對待它——撤銷權杖即可切斷存取。',
       title: '權杖已建立 — 立即複製',
       title: '權杖已建立 — 立即複製',
       warning:
       warning:
         '這是此權杖唯一一次可見。關閉此對話框後您將無法再次查看。',
         '這是此權杖唯一一次可見。關閉此對話框後您將無法再次查看。',

+ 44 - 4
frontend/src/pages/CameraTokensPage.tsx

@@ -101,6 +101,7 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
         >
         >
           <option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
           <option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
           <option value="camwall">{t('cameraTokens.scope.camwall', 'Cam Wall')}</option>
           <option value="camwall">{t('cameraTokens.scope.camwall', 'Cam Wall')}</option>
+          <option value="overlay">{t('cameraTokens.scope.overlay', 'Streaming Overlay')}</option>
         </select>
         </select>
         <input
         <input
           type="number"
           type="number"
@@ -133,10 +134,15 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
               'cameraTokens.create.hintCamWall',
               'cameraTokens.create.hintCamWall',
               'A Cam Wall token opens /camwall on a screen with no login — it can see every printer\'s name and state, and their camera streams. It cannot see filenames, addresses or access codes.',
               'A Cam Wall token opens /camwall on a screen with no login — it can see every printer\'s name and state, and their camera streams. It cannot see filenames, addresses or access codes.',
             )
             )
-          : t(
-              'cameraTokens.create.hintCameraStream',
-              'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
-            )}
+          : scope === 'overlay'
+            ? t(
+                'cameraTokens.create.hintOverlay',
+                'A Streaming Overlay token opens /overlay/{printerId} on a screen with no login — for OBS or any live stream. It can see one printer\'s camera stream plus its live print status, including the filename shown on screen. It cannot see addresses or access codes.',
+              )
+            : t(
+                'cameraTokens.create.hintCameraStream',
+                'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
+              )}
       </p>
       </p>
       <p className="text-xs text-bambu-gray mt-1">
       <p className="text-xs text-bambu-gray mt-1">
         {t(
         {t(
@@ -217,6 +223,14 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
       ? `${window.location.origin}/camwall?token=${encodeURIComponent(plaintext)}`
       ? `${window.location.origin}/camwall?token=${encodeURIComponent(plaintext)}`
       : null;
       : null;
 
 
+  // For an overlay token, likewise the artefact is the URL. It targets one
+  // printer, so we template printer 1 and tell the user to swap in the number
+  // from the printer's URL on the main page (#2613).
+  const overlayUrl =
+    token.scope === 'overlay' && plaintext
+      ? `${window.location.origin}/overlay/1?token=${encodeURIComponent(plaintext)}`
+      : null;
+
   const copyText = async (value: string) => {
   const copyText = async (value: string) => {
     if (!value) return;
     if (!value) return;
     try {
     try {
@@ -300,6 +314,32 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
             </div>
             </div>
           </div>
           </div>
         )}
         )}
+        {overlayUrl && (
+          <div className="mb-4">
+            <p className="text-sm font-medium text-white mb-1">
+              {t('cameraTokens.created.overlayUrlTitle', 'Overlay URL for OBS')}
+            </p>
+            <p className="text-xs text-bambu-gray mb-2">
+              {t(
+                'cameraTokens.created.overlayUrlHint',
+                'Add this as a Browser Source in OBS. Change the /overlay/1 number to your printer\'s number (from its URL on the Printers page). Anyone who can read the URL can watch the stream, so treat it like a key — revoke the token to cut it off.',
+              )}
+            </p>
+            <div className="flex items-center gap-2">
+              <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
+                {overlayUrl}
+              </code>
+              <button
+                type="button"
+                onClick={() => copyText(overlayUrl)}
+                className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
+              >
+                <Copy className="w-4 h-4" />
+                {t('cameraTokens.created.copy', 'Copy')}
+              </button>
+            </div>
+          </div>
+        )}
         <div className="flex justify-end">
         <div className="flex justify-end">
           <button
           <button
             type="button"
             type="button"

+ 51 - 17
frontend/src/pages/StreamOverlayPage.tsx

@@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { api, ApiError, withStreamToken } from '../api/client';
-import type { PrinterStatus } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
@@ -57,7 +56,9 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
   };
   };
 }
 }
 
 
-function getStatusText(status: PrinterStatus, t: TFunction): string {
+// Accepts the minimal shape shared by PrinterStatus (logged-in path) and the
+// token-authed OverlayStatus (kiosk path) — both carry state + stg_cur_name.
+function getStatusText(status: { state: string | null; stg_cur_name?: string | null }, t: TFunction): string {
   if (status.stg_cur_name) return status.stg_cur_name;
   if (status.stg_cur_name) return status.stg_cur_name;
 
 
   switch (status.state) {
   switch (status.state) {
@@ -117,32 +118,59 @@ export function StreamOverlayPage() {
   const config = useMemo(() => parseConfig(searchParams), [searchParams]);
   const config = useMemo(() => parseConfig(searchParams), [searchParams]);
   const sizes = getSizeClasses(config.size);
   const sizes = getSizeClasses(config.size);
 
 
-  // Fetch printer info
-  const { data: printer } = useQuery({
+  // Kiosk mode (#2613): OBS and other embeds have no login session, so they
+  // pass an `overlay`-scoped token in the URL. When present, every data call
+  // (status + camera stream) is authenticated by that token instead of a JWT.
+  const token = searchParams.get('token');
+  const kiosk = token != null && token !== '';
+
+  // Kiosk path: one token-authenticated call for name + live status + the one
+  // setting the overlay reads. No JWT, so this is the only feed available.
+  const { data: overlay } = useQuery({
+    queryKey: ['overlayStatus', id, token],
+    queryFn: () => api.getOverlayStatus(id, token ?? undefined),
+    enabled: id > 0 && kiosk,
+    refetchInterval: 2000,
+  });
+
+  // Logged-in path: the ordinary JWT-authenticated queries, unchanged. Disabled
+  // in kiosk mode so an unauthenticated OBS browser never fires a doomed 401.
+  const { data: printerData } = useQuery({
     queryKey: ['printer', id],
     queryKey: ['printer', id],
     queryFn: () => api.getPrinter(id),
     queryFn: () => api.getPrinter(id),
-    enabled: id > 0,
+    enabled: id > 0 && !kiosk,
   });
   });
 
 
-  // Fetch printer status with polling
-  const { data: status } = useQuery({
+  const { data: statusData } = useQuery({
     queryKey: ['printerStatus', id],
     queryKey: ['printerStatus', id],
     queryFn: () => api.getPrinterStatus(id),
     queryFn: () => api.getPrinterStatus(id),
-    enabled: id > 0,
+    enabled: id > 0 && !kiosk,
     refetchInterval: 2000,
     refetchInterval: 2000,
   });
   });
 
 
-  // Fetch settings info
   const { data: settings } = useQuery({
   const { data: settings } = useQuery({
     queryKey: ['settings'],
     queryKey: ['settings'],
     queryFn: api.getSettings,
     queryFn: api.getSettings,
+    enabled: !kiosk,
   });
   });
 
 
-  const timeFormat: TimeFormat = settings?.time_format || 'system';
+  // Normalize the two sources into the shape the render below reads. Memoized
+  // because the title effect depends on `printer` — a fresh object literal each
+  // render would re-run it (and reset document.title) on every poll tick.
+  const printer = useMemo(
+    () =>
+      kiosk
+        ? overlay && { name: overlay.name, camera_rotation: overlay.camera_rotation }
+        : printerData,
+    [kiosk, overlay, printerData],
+  );
+  const status = kiosk ? overlay : statusData;
+  const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
 
 
-  // WebSocket for real-time updates
+  // WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
+  // where the token can't mint a ws-token — the 2s poll above is the feed).
   useEffect(() => {
   useEffect(() => {
-    if (!id) return;
+    if (!id || kiosk) return;
 
 
     let ws: WebSocket | null = null;
     let ws: WebSocket | null = null;
     let cancelled = false;
     let cancelled = false;
@@ -153,10 +181,10 @@ export function StreamOverlayPage() {
     // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
     // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
     // succeed even without a token.
     // succeed even without a token.
     (async () => {
     (async () => {
-      let token: string | undefined;
+      let wsToken: string | undefined;
       try {
       try {
         const resp = await api.getWebSocketToken();
         const resp = await api.getWebSocketToken();
-        token = resp.token;
+        wsToken = resp.token;
       } catch (err) {
       } catch (err) {
         // A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an
         // A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an
         // auth decision — a tokenless socket would just be closed 4401, so
         // auth decision — a tokenless socket would just be closed 4401, so
@@ -171,7 +199,7 @@ export function StreamOverlayPage() {
       if (cancelled) return;
       if (cancelled) return;
 
 
       const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
       const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
-      const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
+      const tokenParam = wsToken ? `?token=${encodeURIComponent(wsToken)}` : '';
       const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
       const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
       ws = new WebSocket(wsUrl);
       ws = new WebSocket(wsUrl);
 
 
@@ -195,7 +223,7 @@ export function StreamOverlayPage() {
       cancelled = true;
       cancelled = true;
       if (ws) ws.close();
       if (ws) ws.close();
     };
     };
-  }, [id, queryClient]);
+  }, [id, kiosk, queryClient]);
 
 
   // Update document title
   // Update document title
   useEffect(() => {
   useEffect(() => {
@@ -230,7 +258,13 @@ export function StreamOverlayPage() {
 
 
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const progress = status.progress || 0;
   const progress = status.progress || 0;
-  const streamUrl = withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`);
+  // Append the kiosk token directly rather than leaning on withStreamToken's
+  // module cache — the cache is populated by an effect and would miss the first
+  // render (a 401 flash before the retry). The logged-in path keeps the cache.
+  const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
+  const streamUrl = kiosk && token
+    ? `${camPath}&token=${encodeURIComponent(token)}`
+    : withStreamToken(camPath);
 
 
   return (
   return (
     <div className="min-h-screen bg-black relative overflow-hidden">
     <div className="min-h-screen bg-black relative overflow-hidden">

+ 39 - 0
frontend/src/utils/interopDefault.ts

@@ -0,0 +1,39 @@
+/**
+ * Unwrap a default import that a bundler's CommonJS->ESM interop may have
+ * wrapped in a module namespace object.
+ *
+ * Some CommonJS packages set `module.exports = { default: X, Named: X }`.
+ * Depending on the bundler (and differing between the browser build, the test
+ * runner, and Node's own ESM loader), `import X from 'pkg'` can hand you that
+ * whole object instead of `X`. Rendering such an object as a React component
+ * throws "Element type is invalid ... got: object" (React error #130) — see
+ * #2616, where react-simple-keyboard's default import arrived as the namespace
+ * object and crashed every SpoolBuddy screen on input focus.
+ *
+ * This returns the value unchanged when it is already a usable React element
+ * type (a function/class component, a tag string, or an object carrying a React
+ * `$$typeof` marker such as forwardRef/memo/lazy). Otherwise it tries `.default`
+ * and then each of `fallbackKeys` in order, returning the first usable one, and
+ * finally falls back to the original value.
+ */
+export function resolveInteropDefault<T = unknown>(value: unknown, fallbackKeys: string[] = []): T {
+  if (isRenderableType(value)) return value as T;
+
+  if (value !== null && typeof value === 'object') {
+    const obj = value as Record<string, unknown>;
+    if (isRenderableType(obj.default)) return obj.default as T;
+    for (const key of fallbackKeys) {
+      if (isRenderableType(obj[key])) return obj[key] as T;
+    }
+  }
+
+  return value as T;
+}
+
+/** True when `v` is something React can render as an element type. */
+function isRenderableType(v: unknown): boolean {
+  if (typeof v === 'function' || typeof v === 'string') return true;
+  // forwardRef / memo / lazy / context objects are valid element types and are
+  // distinguished from a plain interop wrapper by their React `$$typeof` marker.
+  return typeof v === 'object' && v !== null && '$$typeof' in v;
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
static/assets/index-CKAbipPc.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 1
static/assets/index-CZwzTgpo.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-DrPqYCte.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- 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-Bvvb3PBX.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
+    <script type="module" crossorigin src="/assets/index-DrPqYCte.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.