Browse Source

feat(#1008): archive auto-purge + dedicated archives:purge permission

  Adds an archive counterpart to the library trash sweeper shipped in the
  previous commit. Unlike the library flow, archives are hard-deleted —
  print history is a decaying timeline, so there is no trash intermediate;
  download or favourite anything you want to keep first.

  Backend
  - New ArchivePurgeService (backend/app/services/archive_purge.py) with
    its own 15-minute scheduler loop and a 24h throttle on actual purge
    runs. Delegates every delete to the existing safety-checked
    ArchiveService.delete_archive so the 3MF, thumbnail, timelapse, source
    3MF, F3D, and photo folder all get cleaned up together with the DB
    row. Per-row session via async_session() avoids commit-per-row churn
    on any caller-passed session.
  - New /archives/purge/{preview,settings} + POST /archives/purge routes
    gated on a dedicated archives:purge permission (not archives:delete_all)
    so admins can delegate bulk-delete to a role without granting
    per-archive delete on other users' rows.
  - seed_default_groups() now backfills both library:purge and
    archives:purge on the Administrators group for upgraded installs —
    the original library:purge was added after Administrators was first
    seeded so the "create if not exists" path skipped existing DBs and
    left admins without the permission.
  - 8 new integration tests (defaults, settings roundtrip, bound
    validation, preview, manual purge, auto-purge enabled path, 24h
    throttle, disabled skip).

  Frontend
  - Settings → Archives card gains an auto-purge toggle + age input (7d
    floor, 10y ceiling, 365d default), with a save-toast on every change.
    The bulk "Purge old" button lives on the Archives page header
    (rightmost, after Upload 3MF) to match the File Manager pattern —
    configuration in Settings, one-shot action on the page.
  - New PurgeArchivesModal mirrors PurgeOldFilesModal: live preview (count
    + total size freed + sample filenames) debounced at 300ms, amber
    "hard-delete, no undo" warning.
  - Admin-only UI gates on archives:purge via the standard hasPermission
    hook; Permission TS union updated.
  - i18n blocks across all 8 locales (en/de full, other 6 English
    fallback per project convention).

  Docs
  - CHANGELOG entry under 0.2.4b1 following the existing library-trash
    entry.
  - bambuddy-wiki archiving.md gains a new "Auto-Purge" section.
  - bambuddy-website features.html gets a matching bullet.

  Verification: python -m ruff check backend/app/ clean; 25 integration
  tests pass (8 archive_purge + 17 library_trash regression); npm run
  build clean.
maziggy 1 month ago
parent
commit
bf511c54cd

+ 1 - 0
CHANGELOG.md

@@ -6,6 +6,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ### Added
 ### Added
 - **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than *N* days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new **Auto-purge** setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New `library:purge` permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable `deleted_at` column on `library_files` with an index (dialect-aware migration: `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL, since raw `DATETIME` is SQLite-only syntax); every `LibraryFile` query site now routes through a new `LibraryFile.active()` classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec.
 - **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than *N* days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new **Auto-purge** setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New `library:purge` permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable `deleted_at` column on `library_files` with an index (dialect-aware migration: `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL, since raw `DATETIME` is SQLite-only syntax); every `LibraryFile` query site now routes through a new `LibraryFile.active()` classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec.
+- **Archive Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008) follow-up) — Settings → Archives now has an auto-purge toggle that hard-deletes print archives older than a configurable age threshold (default 365 days, min 7, max 10 years) plus a **Purge archives now** button with the same live-preview modal as the library purge. Unlike the library trash, archives are hard-deleted — print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked `ArchiveService.delete_archive` path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Admin-only (reuses `archives:delete_all`); 8 new backend integration tests; localised across all 8 UI languages.
 - **MakerWorld Integration** — Paste any `makerworld.com/models/…` URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. `LibraryFile` now tracks `source_type` + `source_url`, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
 - **MakerWorld Integration** — Paste any `makerworld.com/models/…` URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. `LibraryFile` now tracks `source_type` + `source_url`, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
   **Endpoint route (non-obvious, ~1 day of reverse engineering)** — Pr0zak/YASTL#51 documented that `makerworld.com`-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at `api.bambulab.com`. The working path turned out to be `GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId}` with `Authorization: Bearer <cloud_token>` — a different service (`iot-service`, not `design-service`) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (``s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…``). The `modelId` query param is the alphanumeric identifier (e.g. ``US2bb73b106683e5``) that only appears in the design response body, *not* the integer ``designId`` from the ``/models/{N}`` URL — so the import flow fetches design metadata first, reads `modelId`, then calls iot-service. S3 presigned URLs must be fetched with ``urllib.request`` (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with ``SignatureDoesNotMatch`` 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
   **Endpoint route (non-obvious, ~1 day of reverse engineering)** — Pr0zak/YASTL#51 documented that `makerworld.com`-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at `api.bambulab.com`. The working path turned out to be `GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId}` with `Authorization: Bearer <cloud_token>` — a different service (`iot-service`, not `design-service`) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (``s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…``). The `modelId` query param is the alphanumeric identifier (e.g. ``US2bb73b106683e5``) that only appears in the design response body, *not* the integer ``designId`` from the ``/models/{N}`` URL — so the import flow fetches design metadata first, reads `modelId`, then calls iot-service. S3 presigned URLs must be fetched with ``urllib.request`` (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with ``SignatureDoesNotMatch`` 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
   **UI and UX features** — per-plate picker with inline **Save** / **Save & Slice in Bambu Studio / OrcaSlicer** buttons, **Import all** to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (**View in File Manager** / **Open in Bambu Studio** / **Open in OrcaSlicer** / **Remove from library**), per-plate delete via the standard Bambuddy confirm modal (no browser `confirm()`), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (`/library/files/{id}/dl/{token}/{filename}`) so the handoff works even with auth enabled. Localised across all eight UI languages.
   **UI and UX features** — per-plate picker with inline **Save** / **Save & Slice in Bambu Studio / OrcaSlicer** buttons, **Import all** to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (**View in File Manager** / **Open in Bambu Studio** / **Open in OrcaSlicer** / **Remove from library**), per-plate delete via the standard Bambuddy confirm modal (no browser `confirm()`), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (`/library/files/{id}/dl/{token}/{filename}`) so the handoff works even with auth enabled. Localised across all eight UI languages.

+ 81 - 0
backend/app/api/routes/archive_purge.py

@@ -0,0 +1,81 @@
+"""Archive auto-purge endpoints (#1008 follow-up).
+
+Admin-only (``ARCHIVES_PURGE``). Provides:
+
+* ``GET /archives/purge/preview`` — live count for the admin slider
+* ``POST /archives/purge`` — one-shot manual bulk delete
+* ``GET/PUT /archives/purge/settings`` — auto-purge toggle + threshold
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_permission_if_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.schemas.archive_purge import (
+    ArchivePurgePreviewResponse,
+    ArchivePurgeRequest,
+    ArchivePurgeResponse,
+    ArchivePurgeSettings,
+)
+from backend.app.services.archive_purge import (
+    MAX_AUTO_PURGE_DAYS,
+    MIN_AUTO_PURGE_DAYS,
+    archive_purge_service,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/archives", tags=["archives-purge"])
+
+
+@router.get("/purge/preview", response_model=ArchivePurgePreviewResponse)
+async def preview_archive_purge(
+    older_than_days: int = Query(ge=1, le=3650),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    """Count + size of archives eligible for purge. Read-only."""
+    result = await archive_purge_service.preview_purge(db, older_than_days=older_than_days)
+    return ArchivePurgePreviewResponse(**result)
+
+
+@router.post("/purge", response_model=ArchivePurgeResponse)
+async def execute_archive_purge(
+    body: ArchivePurgeRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    """Hard-delete archives older than the threshold. Irreversible."""
+    deleted = await archive_purge_service.purge_older_than(db, older_than_days=body.older_than_days)
+    return ArchivePurgeResponse(deleted=deleted)
+
+
+@router.get("/purge/settings", response_model=ArchivePurgeSettings)
+async def get_archive_purge_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    cfg = await archive_purge_service.get_settings(db)
+    return ArchivePurgeSettings(enabled=cfg["enabled"], days=cfg["days"])
+
+
+@router.put("/purge/settings", response_model=ArchivePurgeSettings)
+async def update_archive_purge_settings(
+    body: ArchivePurgeSettings,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    if body.days < MIN_AUTO_PURGE_DAYS or body.days > MAX_AUTO_PURGE_DAYS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"days must be between {MIN_AUTO_PURGE_DAYS} and {MAX_AUTO_PURGE_DAYS}",
+        )
+    saved = await archive_purge_service.set_settings(db, enabled=body.enabled, days=body.days)
+    return ArchivePurgeSettings(enabled=saved["enabled"], days=saved["days"])

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

@@ -1723,6 +1723,24 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
+        # Backfill library:purge + archives:purge for the Administrators group
+        # on existing installs. Both permissions were added after Administrators
+        # was first seeded, so upgrading users miss them even though the default
+        # config (ALL_PERMISSIONS) includes them for fresh installs.
+        result = await session.execute(select(Group).where(Group.name == "Administrators"))
+        admin_group = result.scalar_one_or_none()
+        if admin_group and admin_group.permissions is not None:
+            perms = list(admin_group.permissions)
+            added = False
+            for new_perm in ("library:purge", "archives:purge"):
+                if new_perm not in perms:
+                    perms.append(new_perm)
+                    added = True
+                    logger.info("Added %s to Administrators group (backfill)", new_perm)
+            if added:
+                admin_group.permissions = perms
+        await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
         if groups_created:
             # Refresh to get newly created groups
             # Refresh to get newly created groups

+ 2 - 0
backend/app/core/permissions.py

@@ -33,6 +33,7 @@ class Permission(StrEnum):
     ARCHIVES_DELETE_ALL = "archives:delete_all"
     ARCHIVES_DELETE_ALL = "archives:delete_all"
     ARCHIVES_REPRINT_OWN = "archives:reprint_own"
     ARCHIVES_REPRINT_OWN = "archives:reprint_own"
     ARCHIVES_REPRINT_ALL = "archives:reprint_all"
     ARCHIVES_REPRINT_ALL = "archives:reprint_all"
+    ARCHIVES_PURGE = "archives:purge"
 
 
     # Queue
     # Queue
     QUEUE_READ = "queue:read"
     QUEUE_READ = "queue:read"
@@ -189,6 +190,7 @@ PERMISSION_CATEGORIES = {
         Permission.ARCHIVES_DELETE_ALL,
         Permission.ARCHIVES_DELETE_ALL,
         Permission.ARCHIVES_REPRINT_OWN,
         Permission.ARCHIVES_REPRINT_OWN,
         Permission.ARCHIVES_REPRINT_ALL,
         Permission.ARCHIVES_REPRINT_ALL,
+        Permission.ARCHIVES_PURGE,
     ],
     ],
     "Queue": [
     "Queue": [
         Permission.QUEUE_READ,
         Permission.QUEUE_READ,

+ 7 - 0
backend/app/main.py

@@ -15,6 +15,7 @@ from sqlalchemy import delete, or_, select, text
 from backend.app.api.routes import (
 from backend.app.api.routes import (
     ams_history,
     ams_history,
     api_keys,
     api_keys,
+    archive_purge,
     archives,
     archives,
     auth,
     auth,
     background_dispatch as background_dispatch_routes,
     background_dispatch as background_dispatch_routes,
@@ -65,6 +66,7 @@ from backend.app.core.database import async_session, engine, init_db
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
+from backend.app.services.archive_purge import archive_purge_service
 from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_ftp import (
     FileNotOnPrinterError,
     FileNotOnPrinterError,
@@ -4200,6 +4202,9 @@ async def lifespan(app: FastAPI):
     # Start the library trash sweeper (#1008)
     # Start the library trash sweeper (#1008)
     await library_trash_service.start_scheduler()
     await library_trash_service.start_scheduler()
 
 
+    # Start the archive auto-purge sweeper (#1008 follow-up)
+    await archive_purge_service.start_scheduler()
+
     # Start AMS history recording
     # Start AMS history recording
     start_ams_history_recording()
     start_ams_history_recording()
 
 
@@ -4239,6 +4244,7 @@ async def lifespan(app: FastAPI):
     github_backup_service.stop_scheduler()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
     library_trash_service.stop_scheduler()
     library_trash_service.stop_scheduler()
+    archive_purge_service.stop_scheduler()
     obico_detection_service.stop()
     obico_detection_service.stop()
     stop_ams_history_recording()
     stop_ams_history_recording()
     stop_runtime_tracking()
     stop_runtime_tracking()
@@ -4544,6 +4550,7 @@ app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
+app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)

+ 25 - 0
backend/app/schemas/archive_purge.py

@@ -0,0 +1,25 @@
+"""Schemas for archive auto-purge (#1008 follow-up)."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class ArchivePurgePreviewResponse(BaseModel):
+    count: int
+    total_bytes: int
+    sample_filenames: list[str]
+    older_than_days: int
+
+
+class ArchivePurgeRequest(BaseModel):
+    older_than_days: int = Field(ge=1, le=3650)
+
+
+class ArchivePurgeResponse(BaseModel):
+    deleted: int
+
+
+class ArchivePurgeSettings(BaseModel):
+    enabled: bool = False
+    days: int = Field(default=365, ge=7, le=3650)

+ 218 - 0
backend/app/services/archive_purge.py

@@ -0,0 +1,218 @@
+"""Archive auto-purge service (#1008 follow-up).
+
+Age-based hard-delete of print archives. Unlike the library trash flow there is
+no soft-delete intermediate — archives are historical print records, so the
+"undo" window the library bin provides doesn't apply here. A user who wants to
+keep an archive should download or favourite it before the purge window elapses.
+
+The sweeper runs on the same 15-minute cadence as the library trash sweeper but
+throttles actual purge runs to once per 24h. Admins can also trigger a manual
+purge from the Settings UI.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core import database as _database
+from backend.app.models.archive import PrintArchive
+from backend.app.models.settings import Settings
+from backend.app.services.archive import ArchiveService
+
+logger = logging.getLogger(__name__)
+
+AUTO_PURGE_ENABLED_KEY = "archive_auto_purge_enabled"
+AUTO_PURGE_DAYS_KEY = "archive_auto_purge_days"
+AUTO_PURGE_LAST_RUN_KEY = "archive_auto_purge_last_run"
+
+DEFAULT_AUTO_PURGE_DAYS = 365
+# 7-day floor mirrors the library auto-purge; anything shorter treats archives
+# as ephemeral which is rarely what anyone wants.
+MIN_AUTO_PURGE_DAYS = 7
+MAX_AUTO_PURGE_DAYS = 3650
+
+
+def _age_cutoff(now: datetime, older_than_days: int) -> datetime:
+    return now - timedelta(days=older_than_days)
+
+
+class ArchivePurgeService:
+    """Manages archive auto-purge sweeper + admin-triggered manual purges."""
+
+    def __init__(self):
+        self._scheduler_task: asyncio.Task | None = None
+        # Match library trash cadence — the 24h throttle keeps actual work rare.
+        self._check_interval = 900
+
+    async def start_scheduler(self):
+        if self._scheduler_task is not None:
+            return
+        logger.info("Starting archive auto-purge sweeper")
+        self._scheduler_task = asyncio.create_task(self._scheduler_loop())
+
+    def stop_scheduler(self):
+        if self._scheduler_task:
+            self._scheduler_task.cancel()
+            self._scheduler_task = None
+            logger.info("Stopped archive auto-purge sweeper")
+
+    async def _scheduler_loop(self):
+        while True:
+            try:
+                await asyncio.sleep(self._check_interval)
+                async with _database.async_session() as db:
+                    await self._maybe_run_auto_purge(db)
+            except asyncio.CancelledError:
+                break
+            except Exception as e:  # pragma: no cover - defensive
+                logger.error("Error in archive auto-purge sweeper: %s", e)
+                await asyncio.sleep(60)
+
+    # ---- Settings -----------------------------------------------------
+
+    @staticmethod
+    async def _read_setting(db: AsyncSession, key: str) -> str | None:
+        result = await db.execute(select(Settings.value).where(Settings.key == key))
+        return result.scalar_one_or_none()
+
+    @staticmethod
+    async def _write_setting(db: AsyncSession, key: str, value: str) -> None:
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        row = result.scalar_one_or_none()
+        if row is None:
+            db.add(Settings(key=key, value=value))
+        else:
+            row.value = value
+
+    async def get_settings(self, db: AsyncSession) -> dict:
+        """Return ``{enabled, days}``. Missing keys default to disabled / 365d."""
+        enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
+        days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
+
+        enabled = (enabled_raw or "false").lower() == "true"
+        try:
+            days = int(days_raw) if days_raw is not None else DEFAULT_AUTO_PURGE_DAYS
+        except (TypeError, ValueError):
+            days = DEFAULT_AUTO_PURGE_DAYS
+        days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, days))
+        return {"enabled": enabled, "days": days}
+
+    async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int) -> dict:
+        clamped_days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, int(days)))
+        await self._write_setting(db, AUTO_PURGE_ENABLED_KEY, "true" if enabled else "false")
+        await self._write_setting(db, AUTO_PURGE_DAYS_KEY, str(clamped_days))
+        await db.commit()
+        return {"enabled": enabled, "days": clamped_days}
+
+    async def _get_last_run(self, db: AsyncSession) -> datetime | None:
+        raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
+        if not raw:
+            return None
+        try:
+            return datetime.fromisoformat(raw.replace("Z", "+00:00"))
+        except ValueError:
+            return None
+
+    async def _stamp_last_run(self, db: AsyncSession, when: datetime) -> None:
+        await self._write_setting(db, AUTO_PURGE_LAST_RUN_KEY, when.isoformat())
+        await db.commit()
+
+    async def _maybe_run_auto_purge(self, db: AsyncSession) -> int:
+        """Run the auto-purge if enabled and >=24h has elapsed since last run."""
+        cfg = await self.get_settings(db)
+        if not cfg["enabled"]:
+            return 0
+
+        now = datetime.now(timezone.utc)
+        last = await self._get_last_run(db)
+        if last is not None and (now - last) < timedelta(hours=24):
+            return 0
+
+        deleted = await self.purge_older_than(db, older_than_days=cfg["days"])
+        await self._stamp_last_run(db, now)
+        if deleted:
+            logger.info(
+                "Archive auto-purge: hard-deleted %d archive(s) (threshold=%d days)",
+                deleted,
+                cfg["days"],
+            )
+        return deleted
+
+    # ---- Preview / purge ---------------------------------------------
+
+    async def preview_purge(
+        self,
+        db: AsyncSession,
+        older_than_days: int,
+        sample_limit: int = 5,
+    ) -> dict:
+        """Count + size of archives eligible for purge. Read-only."""
+        if older_than_days < 1:
+            return {
+                "count": 0,
+                "total_bytes": 0,
+                "sample_filenames": [],
+                "older_than_days": older_than_days,
+            }
+        now = datetime.now(timezone.utc)
+        cutoff = _age_cutoff(now, older_than_days)
+        clause = PrintArchive.created_at < cutoff
+
+        count_result = await db.execute(select(func.count(PrintArchive.id)).where(clause))
+        count = int(count_result.scalar() or 0)
+
+        size_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause))
+        total_bytes = int(size_result.scalar() or 0)
+
+        sample_result = await db.execute(
+            select(PrintArchive.filename).where(clause).order_by(PrintArchive.created_at).limit(sample_limit)
+        )
+        samples = [row[0] for row in sample_result.all()]
+
+        return {
+            "count": count,
+            "total_bytes": total_bytes,
+            "sample_filenames": samples,
+            "older_than_days": older_than_days,
+        }
+
+    async def purge_older_than(self, db: AsyncSession, older_than_days: int) -> int:
+        """Hard-delete archives older than ``older_than_days``. Returns count.
+
+        Delegates to :meth:`ArchiveService.delete_archive` for every row so the
+        on-disk cleanup (3MF, thumbnail, timelapse, photos) goes through the
+        same safety-checked path as manual deletion. Each delete runs in its
+        own session so a commit-per-row doesn't churn the caller's session
+        (and matches how the sweeper uses :func:`_database.async_session` in production).
+        """
+        if older_than_days < 1:
+            return 0
+        now = datetime.now(timezone.utc)
+        cutoff = _age_cutoff(now, older_than_days)
+
+        id_result = await db.execute(select(PrintArchive.id).where(PrintArchive.created_at < cutoff))
+        ids = [row[0] for row in id_result.all()]
+        if not ids:
+            return 0
+
+        deleted = 0
+        for archive_id in ids:
+            async with _database.async_session() as delete_db:
+                service = ArchiveService(delete_db)
+                if await service.delete_archive(archive_id):
+                    deleted += 1
+        if deleted:
+            logger.info(
+                "Archive purge: hard-deleted %d archive(s) (older_than_days=%d)",
+                deleted,
+                older_than_days,
+            )
+        return deleted
+
+
+archive_purge_service = ArchivePurgeService()

+ 164 - 0
backend/tests/integration/test_archive_purge_api.py

@@ -0,0 +1,164 @@
+"""Integration tests for archive auto-purge (#1008 follow-up)."""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_settings_defaults_when_unset(async_client: AsyncClient):
+    """GET /archives/purge/settings returns sensible defaults on a fresh install."""
+    resp = await async_client.get("/api/v1/archives/purge/settings")
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["enabled"] is False
+    assert body["days"] == 365
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_settings_roundtrip(async_client: AsyncClient):
+    """PUT persists, GET returns the saved values, days is clamped."""
+    resp = await async_client.put(
+        "/api/v1/archives/purge/settings",
+        json={"enabled": True, "days": 180},
+    )
+    assert resp.status_code == 200
+    assert resp.json() == {"enabled": True, "days": 180}
+
+    resp = await async_client.get("/api/v1/archives/purge/settings")
+    assert resp.json() == {"enabled": True, "days": 180}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_settings_rejects_out_of_range_days(async_client: AsyncClient):
+    """days below MIN or above MAX is rejected."""
+    resp = await async_client.put(
+        "/api/v1/archives/purge/settings",
+        json={"enabled": True, "days": 1},
+    )
+    # Pydantic validation returns 422; explicit bound check returns 400.
+    assert resp.status_code in (400, 422)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_preview_counts_old_archives(async_client: AsyncClient, archive_factory, printer_factory, db_session):
+    """Preview returns the count + total bytes of archives older than the threshold."""
+    printer = await printer_factory()
+    old = await archive_factory(printer.id, print_name="Old", file_size=1000)
+    fresh = await archive_factory(printer.id, print_name="Fresh", file_size=2000)
+
+    old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
+    await db_session.commit()
+
+    resp = await async_client.get("/api/v1/archives/purge/preview?older_than_days=365")
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["count"] == 1
+    assert body["total_bytes"] == 1000
+    assert "Old" in body["sample_filenames"][0] or old.filename in body["sample_filenames"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_manual_purge_deletes_old_archives(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """POST /archives/purge hard-deletes archives older than the threshold."""
+    from backend.app.models.archive import PrintArchive
+
+    printer = await printer_factory()
+    old = await archive_factory(printer.id, print_name="Old")
+    fresh = await archive_factory(printer.id, print_name="Fresh")
+
+    old_id = old.id
+    fresh_id = fresh.id
+    old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
+    await db_session.commit()
+
+    resp = await async_client.post(
+        "/api/v1/archives/purge",
+        json={"older_than_days": 365},
+    )
+    assert resp.status_code == 200
+    assert resp.json()["deleted"] == 1
+
+    # Old is gone, fresh remains.
+    db_session.expire_all()
+    assert await db_session.get(PrintArchive, old_id) is None
+    assert await db_session.get(PrintArchive, fresh_id) is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_runs_when_enabled(async_client: AsyncClient, archive_factory, printer_factory, db_session):
+    """With the toggle on, a stale archive is hard-deleted by the sweeper.
+
+    ``async_client`` is included solely so its fixture activates the module-level
+    ``async_session`` patches that let :meth:`purge_older_than`'s per-row
+    delete sessions reach the in-memory test database.
+    """
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.archive_purge import archive_purge_service
+
+    printer = await printer_factory()
+    stale = await archive_factory(printer.id, print_name="Stale")
+    stale_id = stale.id
+    stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    await db_session.commit()
+
+    await archive_purge_service.set_settings(db_session, enabled=True, days=365)
+
+    deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
+    assert deleted >= 1
+
+    db_session.expire_all()
+    assert await db_session.get(PrintArchive, stale_id) is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_throttles_within_24h(async_client: AsyncClient, archive_factory, printer_factory, db_session):
+    """A recent last-run timestamp blocks the sweeper for 24h."""
+    from backend.app.services.archive_purge import archive_purge_service
+
+    printer = await printer_factory()
+    stale = await archive_factory(printer.id, print_name="Stale")
+    stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    await db_session.commit()
+
+    await archive_purge_service.set_settings(db_session, enabled=True, days=365)
+    # Stamp a last-run time 1h ago — should block the sweeper for another 23h.
+    await archive_purge_service._stamp_last_run(db_session, datetime.now(timezone.utc) - timedelta(hours=1))
+
+    deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
+    assert deleted == 0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_skipped_when_disabled(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """When the toggle is off, old archives stay put."""
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.archive_purge import archive_purge_service
+
+    printer = await printer_factory()
+    stale = await archive_factory(printer.id, print_name="Stale")
+    stale_id = stale.id
+    stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    await db_session.commit()
+
+    await archive_purge_service.set_settings(db_session, enabled=False, days=365)
+    deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
+    assert deleted == 0
+
+    db_session.expire_all()
+    assert await db_session.get(PrintArchive, stale_id) is not None

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

@@ -2298,7 +2298,7 @@ export type Permission =
   | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate'
   | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate'
   | 'archives:read' | 'archives:create'
   | 'archives:read' | 'archives:create'
   | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'
   | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'
-  | 'archives:reprint_own' | 'archives:reprint_all'
+  | 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge'
   | 'queue:read' | 'queue:create'
   | 'queue:read' | 'queue:create'
   | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
   | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
   | 'queue:reorder'
   | 'queue:reorder'
@@ -3078,6 +3078,23 @@ export const api = {
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
   deleteArchive: (id: number) =>
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
+
+  // ========== Archive auto-purge (#1008 follow-up) ==========
+  previewArchivePurge: (olderThanDays: number) =>
+    request<ArchivePurgePreview>(`/archives/purge/preview?older_than_days=${olderThanDays}`),
+  executeArchivePurge: (olderThanDays: number) =>
+    request<{ deleted: number }>('/archives/purge', {
+      method: 'POST',
+      body: JSON.stringify({ older_than_days: olderThanDays }),
+    }),
+  getArchivePurgeSettings: () =>
+    request<ArchivePurgeSettings>('/archives/purge/settings'),
+  updateArchivePurgeSettings: (body: ArchivePurgeSettings) =>
+    request<ArchivePurgeSettings>('/archives/purge/settings', {
+      method: 'PUT',
+      body: JSON.stringify(body),
+    }),
+
   getArchiveStats: (options?: { dateFrom?: string; dateTo?: string; createdById?: number }) => {
   getArchiveStats: (options?: { dateFrom?: string; dateTo?: string; createdById?: number }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
@@ -5156,6 +5173,18 @@ export interface LibraryTrashSettings {
   auto_purge_include_never_printed: boolean;
   auto_purge_include_never_printed: boolean;
 }
 }
 
 
+export interface ArchivePurgePreview {
+  count: number;
+  total_bytes: number;
+  sample_filenames: string[];
+  older_than_days: number;
+}
+
+export interface ArchivePurgeSettings {
+  enabled: boolean;
+  days: number;
+}
+
 export interface LibraryFileUploadResponse {
 export interface LibraryFileUploadResponse {
   id: number;
   id: number;
   filename: string;
   filename: string;

+ 160 - 0
frontend/src/components/PurgeArchivesModal.tsx

@@ -0,0 +1,160 @@
+import { useEffect, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react';
+
+import { api } from '../api/client';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { formatFileSize } from '../utils/file';
+
+interface PurgeArchivesModalProps {
+  onClose: () => void;
+  initialDays?: number;
+}
+
+const DEFAULT_DAYS = 365;
+
+export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [days, setDays] = useState(initialDays ?? DEFAULT_DAYS);
+
+  const [debouncedDays, setDebouncedDays] = useState(days);
+  useEffect(() => {
+    const handle = window.setTimeout(() => setDebouncedDays(days), 300);
+    return () => window.clearTimeout(handle);
+  }, [days]);
+
+  const previewQuery = useQuery({
+    queryKey: ['archive-purge-preview', debouncedDays],
+    queryFn: () => api.previewArchivePurge(debouncedDays),
+    enabled: debouncedDays >= 1,
+  });
+
+  const purgeMutation = useMutation({
+    mutationFn: () => api.executeArchivePurge(days),
+    onSuccess: (res) => {
+      showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      queryClient.invalidateQueries({ queryKey: ['archive-stats'] });
+      onClose();
+    },
+    onError: (e: Error) => showToast(e.message || t('archivePurge.toast.failed'), 'error'),
+  });
+
+  useEffect(() => {
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !purgeMutation.isPending) onClose();
+    };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [onClose, purgeMutation.isPending]);
+
+  const preview = previewQuery.data;
+  const count = preview?.count ?? 0;
+  const totalBytes = preview?.total_bytes ?? 0;
+  const canConfirm = count > 0 && !purgeMutation.isPending;
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-lg w-full">
+        <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
+            <Trash2 className="w-5 h-5" />
+            {t('archivePurge.title')}
+          </h2>
+          <button
+            onClick={onClose}
+            className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+            aria-label={t('common.close')}
+            disabled={purgeMutation.isPending}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-4 space-y-4">
+          <p className="text-sm text-gray-600 dark:text-gray-400">
+            {t('archivePurge.description')}
+          </p>
+
+          <div>
+            <label htmlFor="archive-purge-days" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+              {t('archivePurge.ageLabel')}
+            </label>
+            <div className="flex items-center gap-3">
+              <input
+                id="archive-purge-days"
+                type="number"
+                min={1}
+                max={3650}
+                value={days}
+                onChange={(e) => setDays(Math.max(1, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)))}
+                className="w-24 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm px-2 py-1 text-gray-900 dark:text-gray-100"
+              />
+              <span className="text-sm text-gray-600 dark:text-gray-400">{t('archivePurge.days')}</span>
+            </div>
+          </div>
+
+          <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
+            {previewQuery.isLoading || previewQuery.isFetching ? (
+              <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
+                <Loader2 className="w-4 h-4 animate-spin" /> {t('archivePurge.previewLoading')}
+              </div>
+            ) : previewQuery.isError ? (
+              <div className="text-sm text-red-600 dark:text-red-400">
+                {(previewQuery.error as Error | null)?.message ?? t('archivePurge.previewFailed')}
+              </div>
+            ) : (
+              <div className="text-sm text-gray-900 dark:text-gray-100">
+                <div className="font-medium">
+                  {t('archivePurge.previewSummary', { count, size: formatFileSize(totalBytes) })}
+                </div>
+                {preview?.sample_filenames && preview.sample_filenames.length > 0 && (
+                  <ul className="mt-2 text-xs text-gray-600 dark:text-gray-400 space-y-0.5 list-disc pl-4">
+                    {preview.sample_filenames.map((name) => (
+                      <li key={name} className="truncate">{name}</li>
+                    ))}
+                    {count > preview.sample_filenames.length && (
+                      <li className="list-none italic text-gray-500">
+                        {t('archivePurge.andMore', { count: count - preview.sample_filenames.length })}
+                      </li>
+                    )}
+                  </ul>
+                )}
+              </div>
+            )}
+          </div>
+
+          <div className="flex gap-2 items-start text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded px-3 py-2">
+            <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
+            <span>{t('archivePurge.warning')}</span>
+          </div>
+        </div>
+
+        <div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
+          <Button variant="secondary" onClick={onClose} disabled={purgeMutation.isPending}>
+            {t('common.cancel')}
+          </Button>
+          <Button
+            variant="danger"
+            disabled={!canConfirm}
+            onClick={() => purgeMutation.mutate()}
+          >
+            {purgeMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin mr-1" />
+                {t('archivePurge.purging')}
+              </>
+            ) : (
+              t('archivePurge.confirmCta', { count })
+            )}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -5221,4 +5221,32 @@ export default {
     includeNeverPrinted: 'Dateien einbeziehen, die nie gedruckt wurden',
     includeNeverPrinted: 'Dateien einbeziehen, die nie gedruckt wurden',
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
   },
   },
+  archivePurge: {
+    headerButton: 'Alte löschen',
+    headerTooltip: 'Alte Archive in einem Rutsch löschen',
+    title: 'Alte Archive löschen',
+    description: 'Archive, die älter als der Schwellenwert sind, werden dauerhaft gelöscht — inklusive Dateien, Vorschaubildern und Timelapses. Kann nicht rückgängig gemacht werden.',
+    ageLabel: 'Archive löschen, die älter sind als',
+    days: 'Tage',
+    previewLoading: 'Prüfe passende Archive…',
+    previewFailed: 'Vorschau konnte nicht erstellt werden.',
+    previewSummary: '{{count}} Archive · {{size}} werden gelöscht',
+    andMore: '…und {{count}} weitere',
+    warning: 'Archive werden endgültig gelöscht — es gibt keinen Papierkorb für Archive. Lade wichtige Archive vorher herunter oder markiere sie als Favorit.',
+    confirmCta: '{{count}} Archiv(e) löschen',
+    purging: 'Lösche…',
+    toast: {
+      success: '{{count}} Archiv(e) gelöscht.',
+      failed: 'Archive konnten nicht gelöscht werden.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Alte Archive automatisch löschen',
+    enableDescription: 'Löscht einmal pro Tag Archive, die älter als der Schwellenwert sind, endgültig. Kein Papierkorb — die Löschung erfolgt sofort.',
+    ageLabel: 'Archive automatisch löschen, die älter sind als',
+    ageDescription: 'Minimum 7 Tage, Maximum 10 Jahre. Löscht Archiv, 3MF, Vorschaubild, Timelapse und Fotos.',
+    days: 'Tage',
+    runNow: 'Archive jetzt löschen',
+    saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
+  },
 };
 };

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

@@ -5229,4 +5229,32 @@ export default {
     includeNeverPrinted: 'Include files that have never been printed',
     includeNeverPrinted: 'Include files that have never been printed',
     saveFailed: 'Could not save auto-purge settings.',
     saveFailed: 'Could not save auto-purge settings.',
   },
   },
+  archivePurge: {
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-delete old archives',
+    title: 'Purge old archives',
+    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
+    ageLabel: 'Delete archives older than',
+    days: 'days',
+    previewLoading: 'Checking how many archives match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    andMore: '…and {{count}} more',
+    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    confirmCta: 'Delete {{count}} archive(s)',
+    purging: 'Deleting…',
+    toast: {
+      success: 'Deleted {{count}} archive(s).',
+      failed: 'Could not purge archives.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Auto-purge old archives',
+    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    days: 'days',
+    runNow: 'Purge archives now',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };
 };

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

@@ -5134,4 +5134,32 @@ export default {
     includeNeverPrinted: 'Inclure les fichiers jamais imprimés',
     includeNeverPrinted: 'Inclure les fichiers jamais imprimés',
     saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.',
     saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.',
   },
   },
+  archivePurge: {
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-delete old archives',
+    title: 'Purge old archives',
+    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
+    ageLabel: 'Delete archives older than',
+    days: 'days',
+    previewLoading: 'Checking how many archives match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    andMore: '…and {{count}} more',
+    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    confirmCta: 'Delete {{count}} archive(s)',
+    purging: 'Deleting…',
+    toast: {
+      success: 'Deleted {{count}} archive(s).',
+      failed: 'Could not purge archives.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Auto-purge old archives',
+    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    days: 'days',
+    runNow: 'Purge archives now',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };
 };

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

@@ -5133,4 +5133,32 @@ export default {
     includeNeverPrinted: 'Includi i file mai stampati',
     includeNeverPrinted: 'Includi i file mai stampati',
     saveFailed: 'Impossibile salvare le impostazioni di eliminazione automatica.',
     saveFailed: 'Impossibile salvare le impostazioni di eliminazione automatica.',
   },
   },
+  archivePurge: {
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-delete old archives',
+    title: 'Purge old archives',
+    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
+    ageLabel: 'Delete archives older than',
+    days: 'days',
+    previewLoading: 'Checking how many archives match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    andMore: '…and {{count}} more',
+    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    confirmCta: 'Delete {{count}} archive(s)',
+    purging: 'Deleting…',
+    toast: {
+      success: 'Deleted {{count}} archive(s).',
+      failed: 'Could not purge archives.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Auto-purge old archives',
+    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    days: 'days',
+    runNow: 'Purge archives now',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };
 };

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

@@ -5172,4 +5172,32 @@ export default {
     includeNeverPrinted: '一度も印刷していないファイルも含める',
     includeNeverPrinted: '一度も印刷していないファイルも含める',
     saveFailed: '自動削除の設定を保存できませんでした。',
     saveFailed: '自動削除の設定を保存できませんでした。',
   },
   },
+  archivePurge: {
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-delete old archives',
+    title: 'Purge old archives',
+    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
+    ageLabel: 'Delete archives older than',
+    days: 'days',
+    previewLoading: 'Checking how many archives match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    andMore: '…and {{count}} more',
+    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    confirmCta: 'Delete {{count}} archive(s)',
+    purging: 'Deleting…',
+    toast: {
+      success: 'Deleted {{count}} archive(s).',
+      failed: 'Could not purge archives.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Auto-purge old archives',
+    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    days: 'days',
+    runNow: 'Purge archives now',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };
 };

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

@@ -5147,4 +5147,32 @@ export default {
     includeNeverPrinted: 'Incluir arquivos que nunca foram impressos',
     includeNeverPrinted: 'Incluir arquivos que nunca foram impressos',
     saveFailed: 'Não foi possível salvar as configurações de limpeza automática.',
     saveFailed: 'Não foi possível salvar as configurações de limpeza automática.',
   },
   },
+  archivePurge: {
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-delete old archives',
+    title: 'Purge old archives',
+    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
+    ageLabel: 'Delete archives older than',
+    days: 'days',
+    previewLoading: 'Checking how many archives match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    andMore: '…and {{count}} more',
+    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    confirmCta: 'Delete {{count}} archive(s)',
+    purging: 'Deleting…',
+    toast: {
+      success: 'Deleted {{count}} archive(s).',
+      failed: 'Could not purge archives.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Auto-purge old archives',
+    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    days: 'days',
+    runNow: 'Purge archives now',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };
 };

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

@@ -5211,4 +5211,32 @@ export default {
     includeNeverPrinted: '包括从未打印过的文件',
     includeNeverPrinted: '包括从未打印过的文件',
     saveFailed: '无法保存自动清理设置。',
     saveFailed: '无法保存自动清理设置。',
   },
   },
+  archivePurge: {
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-delete old archives',
+    title: 'Purge old archives',
+    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
+    ageLabel: 'Delete archives older than',
+    days: 'days',
+    previewLoading: 'Checking how many archives match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    andMore: '…and {{count}} more',
+    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    confirmCta: 'Delete {{count}} archive(s)',
+    purging: 'Deleting…',
+    toast: {
+      success: 'Deleted {{count}} archive(s).',
+      failed: 'Could not purge archives.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Auto-purge old archives',
+    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    days: 'days',
+    runNow: 'Purge archives now',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };
 };

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

@@ -5211,4 +5211,32 @@ export default {
     includeNeverPrinted: '包括從未列印過的檔案',
     includeNeverPrinted: '包括從未列印過的檔案',
     saveFailed: '無法儲存自動清理設定。',
     saveFailed: '無法儲存自動清理設定。',
   },
   },
+  archivePurge: {
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-delete old archives',
+    title: 'Purge old archives',
+    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
+    ageLabel: 'Delete archives older than',
+    days: 'days',
+    previewLoading: 'Checking how many archives match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    andMore: '…and {{count}} more',
+    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    confirmCta: 'Delete {{count}} archive(s)',
+    purging: 'Deleting…',
+    toast: {
+      success: 'Deleted {{count}} archive(s).',
+      failed: 'Could not purge archives.',
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: 'Auto-purge old archives',
+    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    days: 'days',
+    runNow: 'Purge archives now',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };
 };

+ 17 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -64,6 +64,7 @@ import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { UploadModal } from '../components/UploadModal';
 import { UploadModal } from '../components/UploadModal';
+import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
@@ -2432,6 +2433,7 @@ export function ArchivesPage() {
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [showTagManagement, setShowTagManagement] = useState(false);
+  const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   const [pendingNavigationArchiveId, setPendingNavigationArchiveId] = useState<number | null>(null);
   const [pendingNavigationArchiveId, setPendingNavigationArchiveId] = useState<number | null>(null);
 
 
@@ -3118,6 +3120,16 @@ export function ArchivesPage() {
             <Upload className="w-4 h-4" />
             <Upload className="w-4 h-4" />
             Upload 3MF
             Upload 3MF
           </Button>
           </Button>
+          {hasPermission('archives:purge') && (
+            <Button
+              variant="secondary"
+              onClick={() => setShowPurgeModal(true)}
+              title={t('archivePurge.headerTooltip')}
+            >
+              <Trash2 className="w-4 h-4 mr-2" />
+              {t('archivePurge.headerButton')}
+            </Button>
+          )}
         </div>
         </div>
       </div>
       </div>
 
 
@@ -3671,6 +3683,11 @@ export function ArchivesPage() {
         />
         />
       )}
       )}
 
 
+      {/* Archive bulk-purge modal (#1008 follow-up) */}
+      {showPurgeModal && (
+        <PurgeArchivesModal onClose={() => setShowPurgeModal(false)} />
+      )}
+
       {/* Bulk Delete Confirmation */}
       {/* Bulk Delete Confirmation */}
       {showBulkDeleteConfirm && (
       {showBulkDeleteConfirm && (
         <ConfirmModal
         <ConfirmModal

+ 1 - 1
frontend/src/pages/LibraryTrashPage.tsx

@@ -138,7 +138,7 @@ export function LibraryTrashPage() {
     onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'),
     onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'),
   });
   });
 
 
-  const items = trashQuery.data?.items ?? [];
+  const items = useMemo(() => trashQuery.data?.items ?? [], [trashQuery.data?.items]);
   const retentionDays = trashQuery.data?.retention_days ?? 30;
   const retentionDays = trashQuery.data?.retention_days ?? 30;
   const totalBytes = useMemo(() => items.reduce((sum, i) => sum + i.file_size, 0), [items]);
   const totalBytes = useMemo(() => items.reduce((sum, i) => sum + i.file_size, 0), [items]);
   const allSelected = items.length > 0 && items.every((i) => selected.has(i.id));
   const allSelected = items.length > 0 && items.every((i) => selected.has(i.id));

+ 77 - 0
frontend/src/pages/SettingsPage.tsx

@@ -493,6 +493,34 @@ export function SettingsPage() {
     });
     });
   };
   };
 
 
+  // Archive auto-purge (#1008 follow-up). Gated on the dedicated archives:purge
+  // permission so admins can delegate bulk-delete to a role without granting
+  // per-archive delete on other users' rows.
+  const canPurgeArchives = !authEnabled || hasPermission('archives:purge');
+  const { data: archivePurgeSettings } = useQuery({
+    queryKey: ['archive-purge-settings'],
+    queryFn: () => api.getArchivePurgeSettings(),
+    enabled: canPurgeArchives,
+  });
+
+  const updateArchivePurgeSettingsMutation = useMutation({
+    mutationFn: (body: { enabled: boolean; days: number }) => api.updateArchivePurgeSettings(body),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archive-purge-settings'] });
+      showToast(t('settings.toast.settingsSaved'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message || t('archiveAutoPurge.saveFailed'), 'error'),
+  });
+
+  const saveArchivePurgeSettings = (patch: Partial<{ enabled: boolean; days: number }>) => {
+    if (!archivePurgeSettings) return;
+    updateArchivePurgeSettingsMutation.mutate({
+      enabled: archivePurgeSettings.enabled,
+      days: archivePurgeSettings.days,
+      ...patch,
+    });
+  };
+
   const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
   const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
     queryKey: ['updateCheck'],
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
     queryFn: api.checkForUpdates,
@@ -1735,6 +1763,55 @@ export function SettingsPage() {
                   </div>
                   </div>
                 </div>
                 </div>
               )}
               )}
+
+              {/* Archive auto-purge (#1008 follow-up). Admin-only — gated on
+                  archives:delete_all. Hard-deletes archives older than the
+                  configured age threshold once per 24h. */}
+              {canPurgeArchives && archivePurgeSettings && (
+                <div className="border-t border-bambu-dark-tertiary pt-3 mt-3 space-y-3">
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-white">{t('archiveAutoPurge.enableLabel')}</p>
+                      <p className="text-sm text-bambu-gray">{t('archiveAutoPurge.enableDescription')}</p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={archivePurgeSettings.enabled}
+                        onChange={(e) => saveArchivePurgeSettings({ enabled: e.target.checked })}
+                        className="sr-only peer"
+                      />
+                      <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      {t('archiveAutoPurge.ageLabel')}
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min={7}
+                        max={3650}
+                        disabled={!archivePurgeSettings.enabled}
+                        value={archivePurgeSettings.days}
+                        onChange={(e) =>
+                          saveArchivePurgeSettings({
+                            days: Math.max(7, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)),
+                          })
+                        }
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
+                      />
+                      <span className="text-bambu-gray">{t('archiveAutoPurge.days')}</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      {t('archiveAutoPurge.ageDescription')}
+                    </p>
+                  </div>
+
+                </div>
+              )}
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- 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-D6c6Ck5Z.js"></script>
+    <script type="module" crossorigin src="/assets/index-CHfSxZ0B.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ByQZwYKW.css">
     <link rel="stylesheet" crossorigin href="/assets/index-ByQZwYKW.css">
   </head>
   </head>
   <body>
   <body>

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