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.
This commit is contained in:
maziggy
2026-04-29 06:54:08 +02:00
parent 418f5168f5
commit c2e7f8eb4b
15 changed files with 336 additions and 135 deletions
+1
View File
File diff suppressed because one or more lines are too long
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"),
@@ -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)
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
+1 -1
View File
@@ -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>