Browse Source

feat(vp): add archive name source toggle (metadata/filename) (#1152)

  Slicer-uploaded archives picked up their display name from the 3MF's
  embedded print_name (the creator-baked title); users who renamed a job
  in BambuStudio's "Send to printer" dialog never saw that name surface
  because the FTP filename was only used as a fallback when metadata was
  empty.

  Settings -> Virtual Printer now exposes an Archive name source toggle
  (Metadata / Filename, default Metadata) that flips precedence in
  ArchiveService.archive_print via a new prefer_filename_for_name param.
  All four VP-sourced archive paths read the new
  virtual_printer_archive_name_source setting and forward the flag:
  _archive_file, _add_to_print_queue, POST /pending-uploads/archive-all,
  POST /pending-uploads/{id}/archive.
maziggy 4 weeks ago
parent
commit
c2e7f8eb4b

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 8 - 0
backend/app/api/routes/pending_uploads.py

@@ -78,6 +78,8 @@ async def archive_all_pending(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Archive all pending uploads."""
+    from backend.app.api.routes.settings import get_setting
+
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
 
@@ -85,6 +87,7 @@ async def archive_all_pending(
     failed = 0
 
     service = ArchiveService(db)
+    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
 
     for pending in pending_uploads:
         file_path = Path(pending.file_path)
@@ -102,6 +105,7 @@ async def archive_all_pending(
                     "source": "virtual_printer",
                     "source_ip": pending.source_ip,
                 },
+                prefer_filename_for_name=prefer_filename,
             )
 
             if archive:
@@ -193,6 +197,9 @@ async def archive_pending_upload(
         raise HTTPException(status_code=404, detail="Upload file not found on disk")
 
     # Archive the file
+    from backend.app.api.routes.settings import get_setting
+
+    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
     service = ArchiveService(db)
     archive = await service.archive_print(
         printer_id=None,
@@ -202,6 +209,7 @@ async def archive_pending_upload(
             "source": "virtual_printer",
             "source_ip": pending.source_ip,
         },
+        prefer_filename_for_name=prefer_filename,
     )
 
     if not archive:

+ 12 - 0
backend/app/api/routes/settings.py

@@ -845,6 +845,7 @@ async def get_virtual_printer_settings(
     target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
     remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
     tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
+    archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
 
     return {
         "enabled": enabled == "true" if enabled else False,
@@ -854,6 +855,7 @@ async def get_virtual_printer_settings(
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "remote_interface_ip": remote_interface_ip or "",
         "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
+        "archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -867,6 +869,7 @@ async def update_virtual_printer_settings(
     target_printer_id: int = None,
     remote_interface_ip: str = None,
     tailscale_disabled: bool = None,
+    archive_name_source: str = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
@@ -930,6 +933,13 @@ async def update_virtual_printer_settings(
             status_code=400,
             content={"detail": "Mode must be 'immediate', 'review', 'print_queue', or 'proxy'"},
         )
+
+    # Validate archive_name_source
+    if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
+        return JSONResponse(
+            status_code=400,
+            content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
+        )
     # Normalize legacy "queue" to "review" for storage
     if new_mode == "queue":
         new_mode = "review"
@@ -1000,6 +1010,8 @@ async def update_virtual_printer_settings(
         await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
     if tailscale_disabled is not None:
         await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
+    if archive_name_source is not None:
+        await set_setting(db, "virtual_printer_archive_name_source", archive_name_source)
 
     # Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
     if tailscale_disabled is not None:

+ 5 - 0
backend/app/schemas/settings.py

@@ -116,6 +116,10 @@ class AppSettings(BaseModel):
         default="immediate",
         description="Mode: 'immediate' (archive now), 'review' (pending review), or 'print_queue' (add to print queue)",
     )
+    virtual_printer_archive_name_source: str = Field(
+        default="metadata",
+        description="Source for the archive's display name on virtual-printer uploads: 'metadata' uses the 3MF's embedded print_name (default, matches Bambu's behavior), 'filename' uses the filename Bambu Studio sent over FTP (lets users rename via the slicer's 'send to printer' dialog).",
+    )
 
     # Dark mode theme settings
     dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant")
@@ -349,6 +353,7 @@ class AppSettingsUpdate(BaseModel):
     virtual_printer_enabled: bool | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_mode: str | None = None
+    virtual_printer_archive_name_source: str | None = None
     dark_style: str | None = None
     dark_background: str | None = None
     dark_accent: str | None = None

+ 7 - 1
backend/app/services/archive.py

@@ -886,6 +886,7 @@ class ArchiveService:
         original_filename: str | None = None,
         project_id: int | None = None,
         subtask_id: str | None = None,
+        prefer_filename_for_name: bool = False,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -901,6 +902,11 @@ class ArchiveService:
             subtask_id: MQTT-provided task identifier (optional). Used to match an
                 existing archive across a backend restart mid-print so the
                 original row can be resumed instead of cancelled (#972).
+            prefer_filename_for_name: When True, use the uploaded filename stem as the
+                archive's display name even if the 3MF embeds a `print_name` in its
+                metadata. Used by virtual-printer flows so users who rename a job in
+                BambuStudio's "send to printer" dialog see that name instead of the
+                creator-baked title (#1152).
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -1028,7 +1034,7 @@ class ArchiveService:
             file_size=dest_file.stat().st_size,
             content_hash=content_hash,
             thumbnail_path=thumbnail_path,
-            print_name=metadata.get("print_name") or display_stem,
+            print_name=display_stem if prefer_filename_for_name else (metadata.get("print_name") or display_stem),
             print_time_seconds=metadata.get("print_time_seconds"),
             filament_used_grams=metadata.get("filament_used_grams"),
             filament_type=metadata.get("filament_type"),

+ 8 - 0
backend/app/services/virtual_printer/manager.py

@@ -236,9 +236,12 @@ class VirtualPrinterInstance:
             return
 
         try:
+            from backend.app.api.routes.settings import get_setting
             from backend.app.services.archive import ArchiveService
 
             async with self._session_factory() as db:
+                name_source = await get_setting(db, "virtual_printer_archive_name_source")
+                prefer_filename = name_source == "filename"
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -248,6 +251,7 @@ class VirtualPrinterInstance:
                         "source": "virtual_printer",
                         "source_ip": source_ip,
                     },
+                    prefer_filename_for_name=prefer_filename,
                 )
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
@@ -309,10 +313,13 @@ class VirtualPrinterInstance:
             return
 
         try:
+            from backend.app.api.routes.settings import get_setting
             from backend.app.models.print_queue import PrintQueueItem
             from backend.app.services.archive import ArchiveService
 
             async with self._session_factory() as db:
+                name_source = await get_setting(db, "virtual_printer_archive_name_source")
+                prefer_filename = name_source == "filename"
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -322,6 +329,7 @@ class VirtualPrinterInstance:
                         "source": "virtual_printer",
                         "source_ip": source_ip,
                     },
+                    prefer_filename_for_name=prefer_filename,
                 )
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)

+ 87 - 8
backend/tests/unit/services/test_virtual_printer.py

@@ -215,10 +215,17 @@ class TestVirtualPrinterInstance:
         mock_archive.id = 1
         mock_archive.print_name = "test"
 
-        with patch(
-            "backend.app.services.archive.ArchiveService.archive_print",
-            new_callable=AsyncMock,
-            return_value=mock_archive,
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
         ):
             await inst._add_to_print_queue(file_path, "192.168.1.100")
 
@@ -266,10 +273,17 @@ class TestVirtualPrinterInstance:
         mock_archive.id = 1
         mock_archive.print_name = "test"
 
-        with patch(
-            "backend.app.services.archive.ArchiveService.archive_print",
-            new_callable=AsyncMock,
-            return_value=mock_archive,
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
         ):
             await inst._add_to_print_queue(file_path, "192.168.1.100")
 
@@ -277,6 +291,71 @@ class TestVirtualPrinterInstance:
         queue_item = added_items[0]
         assert queue_item.manual_start is True
 
+    # ========================================================================
+    # Tests for archive_name_source setting (#1152)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("setting_value", "expected_prefer_filename"),
+        [
+            ("filename", True),
+            ("metadata", False),
+            (None, False),  # Default when setting unset
+            ("", False),  # Defensive: empty string is not "filename"
+        ],
+    )
+    async def test_archive_file_passes_prefer_filename_per_setting(
+        self, tmp_path, setting_value, expected_prefer_filename
+    ):
+        """_archive_file reads `virtual_printer_archive_name_source` and forwards
+        prefer_filename_for_name=True only when it equals 'filename' (#1152)."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=20,
+            name="NameSource",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800020",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "user-renamed-job.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "user-renamed-job"
+
+        archive_print_mock = AsyncMock(return_value=mock_archive)
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=setting_value,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                archive_print_mock,
+            ),
+        ):
+            await inst._archive_file(file_path, "192.168.1.100")
+
+        assert archive_print_mock.await_count == 1
+        kwargs = archive_print_mock.await_args.kwargs
+        assert kwargs.get("prefer_filename_for_name") is expected_prefer_filename
+
 
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""

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

@@ -5554,6 +5554,7 @@ export interface VirtualPrinterSettings {
   target_printer_id: number | null;  // For proxy mode
   remote_interface_ip: string | null;  // For SSDP proxy across networks
   tailscale_disabled: boolean;
+  archive_name_source: 'metadata' | 'filename';  // Source for archive's display name
   status: VirtualPrinterStatus;
 }
 
@@ -5597,6 +5598,7 @@ export const virtualPrinterApi = {
     target_printer_id?: number;
     remote_interface_ip?: string;
     tailscale_disabled?: boolean;
+    archive_name_source?: 'metadata' | 'filename';
   }) => {
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -5606,6 +5608,7 @@ export const virtualPrinterApi = {
     if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
     if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
     if (data.tailscale_disabled !== undefined) params.set('tailscale_disabled', String(data.tailscale_disabled));
+    if (data.archive_name_source !== undefined) params.set('archive_name_source', data.archive_name_source);
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',

+ 58 - 3
frontend/src/components/VirtualPrinterList.tsx

@@ -1,15 +1,19 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { useQuery } from '@tanstack/react-query';
-import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info } from 'lucide-react';
-import { multiVirtualPrinterApi } from '../api/client';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info, FileText } from 'lucide-react';
+import { multiVirtualPrinterApi, virtualPrinterApi } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { useToast } from '../contexts/ToastContext';
 import { VirtualPrinterCard } from './VirtualPrinterCard';
 import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
 
 export function VirtualPrinterList() {
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
   const [showAddDialog, setShowAddDialog] = useState(false);
 
   const { data, isLoading } = useQuery({
@@ -18,6 +22,25 @@ export function VirtualPrinterList() {
     refetchInterval: 10000,
   });
 
+  const { data: globalSettings } = useQuery({
+    queryKey: ['virtual-printer-settings'],
+    queryFn: virtualPrinterApi.getSettings,
+  });
+
+  const archiveNameSourceMutation = useMutation({
+    mutationFn: (source: 'metadata' | 'filename') =>
+      virtualPrinterApi.updateSettings({ archive_name_source: source }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
+      showToast(t('virtualPrinter.toast.updated'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
+    },
+  });
+
+  const useFilename = globalSettings?.archive_name_source === 'filename';
+
   if (isLoading) {
     return (
       <Card>
@@ -73,6 +96,38 @@ export function VirtualPrinterList() {
         </Card>
       </div>
 
+      {/* Global VP behavior settings */}
+      <Card>
+        <CardContent className="py-3 px-4">
+          <div className="flex items-start gap-3">
+            <FileText className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
+            <div className="flex-1 min-w-0">
+              <div className="flex items-center justify-between gap-3">
+                <p className="text-sm text-white font-medium">
+                  {t('virtualPrinter.archiveNameSource.title')}
+                </p>
+                <div className="flex items-center gap-2">
+                  <span className={`text-xs ${useFilename ? 'text-bambu-gray' : 'text-white'}`}>
+                    {t('virtualPrinter.archiveNameSource.metadata')}
+                  </span>
+                  <Toggle
+                    checked={useFilename}
+                    onChange={(checked) => archiveNameSourceMutation.mutate(checked ? 'filename' : 'metadata')}
+                    disabled={archiveNameSourceMutation.isPending}
+                  />
+                  <span className={`text-xs ${useFilename ? 'text-white' : 'text-bambu-gray'}`}>
+                    {t('virtualPrinter.archiveNameSource.filename')}
+                  </span>
+                </div>
+              </div>
+              <p className="text-xs text-bambu-gray mt-1">
+                {t('virtualPrinter.archiveNameSource.description')}
+              </p>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
       {/* Header with add button */}
       <div className="flex items-center justify-between">
         <div className="flex items-center gap-2">

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

@@ -4022,6 +4022,12 @@ export default {
       description: 'Die virtuelle Druckerfunktion erfordert zusätzliche Systemkonfiguration, bevor sie funktioniert. Dies beinhaltet Portweiterleitung, Firewall-Regeln und plattformspezifische Einstellungen.',
       readGuide: 'Lese die Einrichtungsanleitung vor dem Aktivieren',
     },
+    archiveNameSource: {
+      title: 'Quelle des Archivnamens',
+      description: 'Lege fest, wie neue Archive benannt werden, wenn Dateien über den virtuellen Drucker eintreffen. "Metadaten" verwendet den im 3MF eingebetteten Titel des Slicers (Standard). "Dateiname" nutzt den Dateinamen, den Bambu Studio per FTP gesendet hat — praktisch, wenn der Job im Dialog "Zum Drucker senden" umbenannt wurde.',
+      metadata: 'Metadaten',
+      filename: 'Dateiname',
+    },
     howItWorks: {
       title: 'So funktioniert es',
       step1: 'Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.',

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

@@ -4031,6 +4031,12 @@ export default {
       description: 'The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.',
       readGuide: 'Read the setup guide before enabling',
     },
+    archiveNameSource: {
+      title: 'Archive name source',
+      description: 'Choose how new archives are named when files arrive via the virtual printer. "Metadata" uses the slicer-embedded title from the 3MF (default). "Filename" uses the filename Bambu Studio sent over FTP — handy if you renamed the job in the "send to printer" dialog.',
+      metadata: 'Metadata',
+      filename: 'Filename',
+    },
     howItWorks: {
       title: 'How it works',
       step1: 'On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.',

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

@@ -4013,6 +4013,12 @@ export default {
       description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',
       readGuide: '启用前请阅读设置指南',
     },
+    archiveNameSource: {
+      title: '存档名称来源',
+      description: '选择通过虚拟打印机接收文件时新存档的命名方式。"元数据"使用 3MF 中嵌入的切片标题(默认)。"文件名"使用 Bambu Studio 通过 FTP 发送的文件名 — 当您在"发送到打印机"对话框中重命名作业时非常有用。',
+      metadata: '元数据',
+      filename: '文件名',
+    },
     howItWorks: {
       title: '工作原理',
       step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',

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

@@ -4013,6 +4013,12 @@ export default {
       description: '虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。',
       readGuide: '啟用前請閱讀設定指南',
     },
+    archiveNameSource: {
+      title: '存檔名稱來源',
+      description: '選擇透過虛擬印表機接收檔案時新存檔的命名方式。"元資料"使用 3MF 中嵌入的切片標題(預設)。"檔名"使用 Bambu Studio 透過 FTP 傳送的檔案名稱 — 當您在"傳送到印表機"對話方塊中重新命名工作時非常有用。',
+      metadata: '元資料',
+      filename: '檔名',
+    },
     howItWorks: {
       title: '工作原理',
       step1: '在同一區域網路中,虛擬印表機會透過發現機制自動出現在您的切片軟體(Bambu Studio / OrcaSlicer)中。從其他網路,透過 IP 位址和存取碼手動新增。',

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-bqAa7OcX.js"></script>
+    <script type="module" crossorigin src="/assets/index-5P0GaR3D.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
   </head>
   <body>

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