Browse Source

● feat(#1008): library trash bin, admin bulk purge, auto-purge setting

  Library files now move to a configurable-retention trash bin on delete
  instead of being hard-deleted from disk (default 30 days). Admins get a
  "Purge old" bulk action on the File Manager with a live preview, plus an
  optional auto-purge setting in Settings → File Manager that runs the same
  operation once per 24h when enabled (default off). Regular users see and
  manage their own trashed files; admins see everyone's. External (linked)
  files bypass trash since their bytes aren't under Bambuddy's control.

  - New `library:purge` permission (admin-only by default)
  - Nullable indexed `deleted_at` column on library_files; dialect-aware
    ALTER TABLE so the column actually gets added on PostgreSQL (raw
    DATETIME is SQLite-only syntax)
  - New `LibraryFile.active()` classmethod; every query site routed through
    it so trashed rows don't leak into listings, print dispatch, MakerWorld
    dedupe, or stats
  - Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
    layout so datetime columns don't clip
  - Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
    the 15-minute sweeper cadence still runs the purge at most once per day
  - Save toast wired into every trash/auto-purge setting change
  - 17 new backend integration tests (service + routes + auto-purge throttle),
    8 new frontend tests, localised across all 8 UI languages
  - Wiki + website feature entries updated
maziggy 1 month ago
parent
commit
e0e597271e
37 changed files with 2994 additions and 80 deletions
  1. 1 0
      CHANGELOG.md
  2. 105 63
      backend/app/api/routes/library.py
  3. 281 0
      backend/app/api/routes/library_trash.py
  4. 4 3
      backend/app/api/routes/makerworld.py
  5. 1 1
      backend/app/api/routes/print_queue.py
  6. 1 1
      backend/app/api/routes/projects.py
  7. 6 1
      backend/app/api/routes/users.py
  8. 17 0
      backend/app/core/database.py
  9. 5 0
      backend/app/core/permissions.py
  10. 7 0
      backend/app/main.py
  11. 17 1
      backend/app/models/library.py
  12. 59 0
      backend/app/schemas/library_trash.py
  13. 1 1
      backend/app/services/background_dispatch.py
  14. 388 0
      backend/app/services/library_trash.py
  15. 3 3
      backend/app/services/print_scheduler.py
  16. 2 2
      backend/app/services/usage_tracker.py
  17. 417 0
      backend/tests/integration/test_library_trash_api.py
  18. 2 0
      frontend/src/App.tsx
  19. 89 0
      frontend/src/__tests__/components/PurgeOldFilesModal.test.tsx
  20. 150 0
      frontend/src/__tests__/pages/LibraryTrashPage.test.tsx
  21. 62 1
      frontend/src/api/client.ts
  22. 173 0
      frontend/src/components/PurgeOldFilesModal.tsx
  23. 82 0
      frontend/src/i18n/locales/de.ts
  24. 82 0
      frontend/src/i18n/locales/en.ts
  25. 82 0
      frontend/src/i18n/locales/fr.ts
  26. 82 0
      frontend/src/i18n/locales/it.ts
  27. 82 0
      frontend/src/i18n/locales/ja.ts
  28. 82 0
      frontend/src/i18n/locales/pt-BR.ts
  29. 82 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 82 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 49 1
      frontend/src/pages/FileManagerPage.tsx
  32. 398 0
      frontend/src/pages/LibraryTrashPage.tsx
  33. 98 0
      frontend/src/pages/SettingsPage.tsx
  34. 0 0
      static/assets/index-ByQZwYKW.css
  35. 0 0
      static/assets/index-D6c6Ck5Z.js
  36. 0 0
      static/assets/index-DequwckK.css
  37. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.4b1] - Unreleased
 
 ### 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.
 - **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.
   **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.

+ 105 - 63
backend/app/api/routes/library.py

@@ -9,6 +9,7 @@ import re
 import shutil
 import uuid
 import zipfile
+from datetime import datetime, timezone
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
@@ -175,7 +176,7 @@ async def save_3mf_bytes_to_library(
     """
     # Source-URL-based dedupe: return the existing row untouched.
     if source_url:
-        existing = await db.execute(select(LibraryFile).where(LibraryFile.source_url == source_url).limit(1))
+        existing = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
         existing_row = existing.scalar_one_or_none()
         if existing_row is not None:
             return existing_row, True
@@ -374,7 +375,7 @@ async def list_folders(
     # Get file counts per folder
     file_counts_result = await db.execute(
         select(LibraryFile.folder_id, func.count(LibraryFile.id))
-        .where(LibraryFile.folder_id.isnot(None))
+        .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
         .group_by(LibraryFile.folder_id)
     )
     file_counts = dict(file_counts_result.all())
@@ -430,7 +431,10 @@ async def get_folders_by_project(
     for folder, project_name in rows:
         # Get file count
         file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id)
+            select(func.count(LibraryFile.id)).where(
+                LibraryFile.folder_id == folder.id,
+                LibraryFile.deleted_at.is_(None),
+            )
         )
         file_count = file_count_result.scalar() or 0
 
@@ -475,7 +479,10 @@ async def get_folders_by_archive(
     for folder, archive_name in rows:
         # Get file count
         file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id)
+            select(func.count(LibraryFile.id)).where(
+                LibraryFile.folder_id == folder.id,
+                LibraryFile.deleted_at.is_(None),
+            )
         )
         file_count = file_count_result.scalar() or 0
 
@@ -582,7 +589,12 @@ async def get_folder(
     folder, project_name, archive_name = row
 
     # Get file count
-    file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id))
+    file_count_result = await db.execute(
+        select(func.count(LibraryFile.id)).where(
+            LibraryFile.folder_id == folder_id,
+            LibraryFile.deleted_at.is_(None),
+        )
+    )
     file_count = file_count_result.scalar() or 0
 
     return FolderResponse(
@@ -668,7 +680,12 @@ async def update_folder(
     await db.refresh(folder)
 
     # Get file count and names
-    file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id))
+    file_count_result = await db.execute(
+        select(func.count(LibraryFile.id)).where(
+            LibraryFile.folder_id == folder_id,
+            LibraryFile.deleted_at.is_(None),
+        )
+    )
     file_count = file_count_result.scalar() or 0
 
     # Get project and archive names
@@ -917,7 +934,7 @@ async def scan_external_folder(
 
     # Get existing DB files across root and all subfolders
     existing_result = await db.execute(
-        select(LibraryFile).where(
+        LibraryFile.active().where(
             LibraryFile.folder_id.in_(all_folder_ids),
             LibraryFile.is_external.is_(True),
         )
@@ -1131,7 +1148,12 @@ async def scan_external_folder(
         if rel_path in seen_rel_dirs:
             continue  # Directory still exists on disk
         # Check if subfolder has any remaining files
-        file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == sub_fid))
+        file_count_result = await db.execute(
+            select(func.count(LibraryFile.id)).where(
+                LibraryFile.folder_id == sub_fid,
+                LibraryFile.deleted_at.is_(None),
+            )
+        )
         if (file_count_result.scalar() or 0) == 0:
             # Check if it has any remaining child folders
             child_count_result = await db.execute(
@@ -1169,7 +1191,7 @@ async def list_files(
         include_root: If True and folder_id is None, returns files at root level.
                      If False and folder_id is None, returns all files.
     """
-    query = select(LibraryFile).options(selectinload(LibraryFile.created_by))
+    query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
 
     if folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
@@ -1191,7 +1213,7 @@ async def list_files(
         if hashes:
             dup_result = await db.execute(
                 select(LibraryFile.file_hash, func.count(LibraryFile.id))
-                .where(LibraryFile.file_hash.in_(hashes))
+                .where(LibraryFile.file_hash.in_(hashes), LibraryFile.deleted_at.is_(None))
                 .group_by(LibraryFile.file_hash)
             )
             hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self
@@ -1277,7 +1299,9 @@ async def upload_file(
         file_hash = calculate_file_hash(file_path)
 
         # Check for duplicates
-        dup_result = await db.execute(select(LibraryFile.id).where(LibraryFile.file_hash == file_hash).limit(1))
+        dup_result = await db.execute(
+            select(LibraryFile.id).where(LibraryFile.file_hash == file_hash, LibraryFile.deleted_at.is_(None)).limit(1)
+        )
         duplicate_of = dup_result.scalar()
 
         # Extract metadata and thumbnail
@@ -1659,7 +1683,7 @@ async def batch_generate_stl_thumbnails(
     results: list[BatchThumbnailResult] = []
 
     # Build query based on request
-    query = select(LibraryFile).where(LibraryFile.file_type == "stl")
+    query = LibraryFile.active().where(LibraryFile.file_type == "stl")
 
     if request.file_ids:
         # Specific files
@@ -1782,7 +1806,7 @@ async def add_files_to_queue(
     errors: list[AddToQueueError] = []
 
     # Get all requested files
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id.in_(request.file_ids)))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     files = {f.id: f for f in result.scalars().all()}
 
     # Get max position for queue ordering
@@ -1862,7 +1886,7 @@ async def get_library_file_plates(
     import defusedxml.ElementTree as ET
 
     # Get the library file
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
 
     if not lib_file:
@@ -2120,7 +2144,7 @@ async def get_library_file_plate_thumbnail(
     """Get the thumbnail image for a specific plate from a library file."""
     from starlette.responses import Response
 
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
 
     if not lib_file:
@@ -2161,7 +2185,7 @@ async def get_library_file_filament_requirements(
     import defusedxml.ElementTree as ET
 
     # Get the library file
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
 
     if not lib_file:
@@ -2299,7 +2323,7 @@ async def print_library_file(
         body = FilePrintRequest()
 
     # Get the library file
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     lib_file = result.scalar_one_or_none()
 
     if not lib_file:
@@ -2378,7 +2402,7 @@ async def get_file(
 ):
     """Get a file by ID with full details."""
     result = await db.execute(
-        select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
+        LibraryFile.active().options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
     )
     file = result.scalar_one_or_none()
 
@@ -2404,7 +2428,11 @@ async def get_file(
         dup_result = await db.execute(
             select(LibraryFile, LibraryFolder.name)
             .outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
-            .where(LibraryFile.file_hash == file.file_hash, LibraryFile.id != file.id)
+            .where(
+                LibraryFile.file_hash == file.file_hash,
+                LibraryFile.id != file.id,
+                LibraryFile.deleted_at.is_(None),
+            )
         )
         for dup_file, dup_folder_name in dup_result.all():
             duplicates.append(
@@ -2473,7 +2501,7 @@ async def update_file(
     """Update a file's metadata."""
     user, can_modify_all = auth_result
 
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
 
     if not file:
@@ -2534,10 +2562,17 @@ async def delete_file(
         )
     ),
 ):
-    """Delete a file."""
+    """Move a file to the trash (soft-delete).
+
+    The file's bytes and thumbnail stay on disk until the trash sweeper
+    hard-deletes the row after the retention window (see #1008). External
+    files skip the trash entirely — they can't be restored from disk and the
+    underlying file is outside Bambuddy's control, so we just drop the DB
+    record and thumbnail.
+    """
     user, can_modify_all = auth_result
 
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
 
     if not file:
@@ -2548,23 +2583,22 @@ async def delete_file(
         if file.created_by_id != user.id:
             raise HTTPException(status_code=403, detail="You can only delete your own files")
 
-    # External files: only remove DB entry and thumbnail, never delete the actual file
-    try:
-        if not file.is_external:
-            abs_file_path = to_absolute_path(file.file_path)
-            if abs_file_path and abs_file_path.exists():
-                abs_file_path.unlink()
-        # Always clean up thumbnails we generated
+    if file.is_external:
+        # External files bypass the trash — just drop the DB row + our thumbnail.
         abs_thumb_path = to_absolute_path(file.thumbnail_path)
         if abs_thumb_path and abs_thumb_path.exists():
-            abs_thumb_path.unlink()
-    except OSError as e:
-        logger.warning("Failed to delete file from disk: %s", e)
+            try:
+                abs_thumb_path.unlink()
+            except OSError as e:
+                logger.warning("Failed to delete thumbnail from disk: %s", e)
+        await db.delete(file)
+        await db.commit()
+        return {"status": "success", "message": "File deleted", "trashed": False}
 
-    await db.delete(file)
+    # Managed file: soft-delete. Sweeper removes bytes + thumbnail after retention.
+    file.deleted_at = datetime.now(timezone.utc)
     await db.commit()
-
-    return {"status": "success", "message": "File deleted"}
+    return {"status": "success", "message": "File moved to trash", "trashed": True}
 
 
 # ============ File Content Endpoints ============
@@ -2577,7 +2611,7 @@ async def download_file(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """Download a file."""
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
 
     if not file:
@@ -2607,7 +2641,7 @@ async def create_library_slicer_token(
     """
     from backend.app.core.auth import create_slicer_download_token
 
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
@@ -2634,7 +2668,7 @@ async def download_library_file_for_slicer(
     if not await verify_slicer_download_token(token, "library", file_id):
         raise HTTPException(status_code=403, detail="Invalid or expired download token")
 
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
@@ -2657,7 +2691,7 @@ async def get_thumbnail(
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get a file's thumbnail."""
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
 
     if not file:
@@ -2688,7 +2722,7 @@ async def get_gcode(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """Get gcode for a file (for preview)."""
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
 
     if not file:
@@ -2751,7 +2785,7 @@ async def move_files(
     moved = 0
     skipped = 0
     for file_id in data.file_ids:
-        result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+        result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
         file = result.scalar_one_or_none()
         if file:
             # Ownership check
@@ -2788,28 +2822,30 @@ async def bulk_delete(
     deleted_folders = 0
     skipped_files = 0
 
-    # Delete files first
+    # Delete files first. Managed files go to trash (sweeper hard-deletes bytes
+    # later); external files bypass trash since their disk state is outside our
+    # control and can't be restored from trash anyway.
+    now = datetime.now(timezone.utc)
     for file_id in data.file_ids:
-        result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+        result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
         file = result.scalar_one_or_none()
-        if file:
-            # Ownership check
-            if not can_modify_all and file.created_by_id != user.id:
-                skipped_files += 1
-                continue
+        if not file:
+            continue
+        if not can_modify_all and file.created_by_id != user.id:
+            skipped_files += 1
+            continue
 
-            try:
-                if not file.is_external:
-                    abs_file_path = to_absolute_path(file.file_path)
-                    if abs_file_path and abs_file_path.exists():
-                        abs_file_path.unlink()
-                abs_thumb_path = to_absolute_path(file.thumbnail_path)
-                if abs_thumb_path and abs_thumb_path.exists():
+        if file.is_external:
+            abs_thumb_path = to_absolute_path(file.thumbnail_path)
+            if abs_thumb_path and abs_thumb_path.exists():
+                try:
                     abs_thumb_path.unlink()
-            except OSError as e:
-                logger.warning("Failed to delete file from disk: %s", e)
+                except OSError as e:
+                    logger.warning("Failed to delete thumbnail from disk: %s", e)
             await db.delete(file)
-            deleted_files += 1
+        else:
+            file.deleted_at = now
+        deleted_files += 1
 
     # Delete folders (cascade will handle contents)
     # Note: Folders don't have ownership tracking currently, require *_all permission
@@ -2823,7 +2859,10 @@ async def bulk_delete(
         if folder:
             # Count files that will be deleted
             file_count_result = await db.execute(
-                select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id)
+                select(func.count(LibraryFile.id)).where(
+                    LibraryFile.folder_id == folder_id,
+                    LibraryFile.deleted_at.is_(None),
+                )
             )
             deleted_files += file_count_result.scalar() or 0
             await db.delete(folder)
@@ -2843,8 +2882,11 @@ async def get_library_stats(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """Get library statistics."""
+    # Stats exclude trashed files — users see counts/sizes for what's actually in the library.
+    active_only = LibraryFile.deleted_at.is_(None)
+
     # Total files
-    total_files_result = await db.execute(select(func.count(LibraryFile.id)))
+    total_files_result = await db.execute(select(func.count(LibraryFile.id)).where(active_only))
     total_files = total_files_result.scalar() or 0
 
     # Total folders
@@ -2852,17 +2894,17 @@ async def get_library_stats(
     total_folders = total_folders_result.scalar() or 0
 
     # Total size
-    total_size_result = await db.execute(select(func.sum(LibraryFile.file_size)))
+    total_size_result = await db.execute(select(func.sum(LibraryFile.file_size)).where(active_only))
     total_size = total_size_result.scalar() or 0
 
     # Files by type
     type_result = await db.execute(
-        select(LibraryFile.file_type, func.count(LibraryFile.id)).group_by(LibraryFile.file_type)
+        select(LibraryFile.file_type, func.count(LibraryFile.id)).where(active_only).group_by(LibraryFile.file_type)
     )
     files_by_type = dict(type_result.all())
 
     # Total prints
-    total_prints_result = await db.execute(select(func.sum(LibraryFile.print_count)))
+    total_prints_result = await db.execute(select(func.sum(LibraryFile.print_count)).where(active_only))
     total_prints = total_prints_result.scalar() or 0
 
     # Disk space info

+ 281 - 0
backend/app/api/routes/library_trash.py

@@ -0,0 +1,281 @@
+"""Library trash bin + admin purge endpoints (#1008).
+
+Permission model:
+
+* **Admin purge** (``/library/purge/*``) and **retention settings**
+  (``/library/trash/settings``) require :attr:`Permission.LIBRARY_PURGE` —
+  admin-only.
+* **Per-user trash** (list / restore / hard-delete / empty own trash) is
+  gated by the existing :attr:`Permission.LIBRARY_DELETE_ALL` /
+  :attr:`Permission.LIBRARY_DELETE_OWN` ownership pair, so a regular user
+  sees their own trashed files and an admin sees everyone's.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.user import User
+from backend.app.schemas.library_trash import (
+    EmptyTrashResponse,
+    PurgePreviewResponse,
+    PurgeRequest,
+    PurgeResponse,
+    TrashFile,
+    TrashListResponse,
+    TrashSettings,
+)
+from backend.app.services.library_trash import (
+    MAX_RETENTION_DAYS,
+    MIN_RETENTION_DAYS,
+    library_trash_service,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library", tags=["library-trash"])
+
+
+# ===================== Admin purge =====================
+
+
+@router.get("/purge/preview", response_model=PurgePreviewResponse)
+async def preview_purge(
+    older_than_days: int = Query(ge=1, le=3650),
+    include_never_printed: bool = True,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    """Preview how many files would move to trash for the given age threshold.
+
+    Read-only — safe to call repeatedly as the admin adjusts the slider.
+    """
+    result = await library_trash_service.preview_purge(
+        db,
+        older_than_days=older_than_days,
+        include_never_printed=include_never_printed,
+    )
+    return PurgePreviewResponse(**result)
+
+
+@router.post("/purge", response_model=PurgeResponse)
+async def execute_purge(
+    body: PurgeRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    """Move matching files to trash. Idempotent — already-trashed rows skip."""
+    moved = await library_trash_service.purge_older_than(
+        db,
+        older_than_days=body.older_than_days,
+        include_never_printed=body.include_never_printed,
+    )
+    return PurgeResponse(moved_to_trash=moved)
+
+
+# ===================== Trash list + per-item ops =====================
+
+
+@router.get("/trash", response_model=TrashListResponse)
+async def list_trash(
+    limit: int = Query(default=100, ge=1, le=500),
+    offset: int = Query(default=0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """List trashed files.
+
+    Admins (``LIBRARY_DELETE_ALL``) see every user's trash; regular users
+    (``LIBRARY_DELETE_OWN``) see only rows they created.
+    """
+    user, can_modify_all = auth_result
+    retention_days = await library_trash_service.get_retention_days(db)
+
+    # Base query: trashed files + their folder name (for the UI) + creator.
+    base_conditions = [LibraryFile.deleted_at.isnot(None)]
+    if not can_modify_all:
+        if user is None:
+            # Defensive: ownership checker only returns user=None when auth is off,
+            # in which case can_modify_all=True. If we somehow land here, err safe.
+            raise HTTPException(status_code=403, detail="Authentication required")
+        base_conditions.append(LibraryFile.created_by_id == user.id)
+
+    total_result = await db.execute(select(func.count(LibraryFile.id)).where(*base_conditions))
+    total = int(total_result.scalar() or 0)
+
+    rows_result = await db.execute(
+        select(LibraryFile, LibraryFolder.name, User.username)
+        .outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        .outerjoin(User, LibraryFile.created_by_id == User.id)
+        .where(*base_conditions)
+        .order_by(LibraryFile.deleted_at.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+
+    items: list[TrashFile] = []
+    for file, folder_name, username in rows_result.all():
+        # deleted_at is not-null by construction above; narrow for the typechecker.
+        assert file.deleted_at is not None
+        auto_purge_at = file.deleted_at + timedelta(days=retention_days)
+        items.append(
+            TrashFile(
+                id=file.id,
+                filename=file.filename,
+                file_size=file.file_size,
+                thumbnail_path=file.thumbnail_path,
+                folder_id=file.folder_id,
+                folder_name=folder_name,
+                created_by_id=file.created_by_id,
+                created_by_username=username,
+                deleted_at=file.deleted_at,
+                auto_purge_at=auto_purge_at,
+            )
+        )
+
+    return TrashListResponse(items=items, total=total, retention_days=retention_days)
+
+
+async def _load_trashed_file(
+    db: AsyncSession,
+    file_id: int,
+    user: User | None,
+    can_modify_all: bool,
+) -> LibraryFile:
+    """Fetch a trashed file, enforcing ownership for non-admins."""
+    result = await db.execute(
+        select(LibraryFile).where(
+            LibraryFile.id == file_id,
+            LibraryFile.deleted_at.isnot(None),
+        )
+    )
+    file = result.scalar_one_or_none()
+    if file is None:
+        raise HTTPException(status_code=404, detail="Trashed file not found")
+    if not can_modify_all:
+        if user is None or file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only manage your own trashed files")
+    return file
+
+
+@router.post("/trash/{file_id}/restore")
+async def restore_from_trash(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    user, can_modify_all = auth_result
+    file = await _load_trashed_file(db, file_id, user, can_modify_all)
+    await library_trash_service.restore(db, file)
+    return {"status": "success", "id": file.id}
+
+
+@router.delete("/trash/{file_id}")
+async def hard_delete_from_trash(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """Permanently delete a single trashed file + its bytes. Irreversible."""
+    user, can_modify_all = auth_result
+    file = await _load_trashed_file(db, file_id, user, can_modify_all)
+    await library_trash_service.hard_delete_now(db, file)
+    return {"status": "success"}
+
+
+@router.delete("/trash", response_model=EmptyTrashResponse)
+async def empty_trash(
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """Permanently delete all trashed files in the caller's scope.
+
+    Regular users empty only their own trash; admins empty everyone's.
+    """
+    user, can_modify_all = auth_result
+    conditions = [LibraryFile.deleted_at.isnot(None)]
+    if not can_modify_all:
+        if user is None:
+            raise HTTPException(status_code=403, detail="Authentication required")
+        conditions.append(LibraryFile.created_by_id == user.id)
+
+    rows_result = await db.execute(select(LibraryFile).where(*conditions))
+    rows = rows_result.scalars().all()
+    deleted = 0
+    for row in rows:
+        await library_trash_service.hard_delete_now(db, row)
+        deleted += 1
+    return EmptyTrashResponse(deleted=deleted)
+
+
+# ===================== Retention settings (admin only) =====================
+
+
+@router.get("/trash/settings", response_model=TrashSettings)
+async def get_trash_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    retention = await library_trash_service.get_retention_days(db)
+    auto = await library_trash_service.get_auto_purge_settings(db)
+    return TrashSettings(
+        retention_days=retention,
+        auto_purge_enabled=auto["enabled"],
+        auto_purge_days=auto["days"],
+        auto_purge_include_never_printed=auto["include_never_printed"],
+    )
+
+
+@router.put("/trash/settings", response_model=TrashSettings)
+async def update_trash_settings(
+    body: TrashSettings,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    if body.retention_days < MIN_RETENTION_DAYS or body.retention_days > MAX_RETENTION_DAYS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"retention_days must be between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS}",
+        )
+    saved_retention = await library_trash_service.set_retention_days(db, body.retention_days)
+    saved_auto = await library_trash_service.set_auto_purge_settings(
+        db,
+        enabled=body.auto_purge_enabled,
+        days=body.auto_purge_days,
+        include_never_printed=body.auto_purge_include_never_printed,
+    )
+    return TrashSettings(
+        retention_days=saved_retention,
+        auto_purge_enabled=saved_auto["enabled"],
+        auto_purge_days=saved_auto["days"],
+        auto_purge_include_never_printed=saved_auto["include_never_printed"],
+    )

+ 4 - 3
backend/app/api/routes/makerworld.py

@@ -189,7 +189,8 @@ async def resolve_url(
     model_prefix = _canonical_url(model_id)
     existing_q = await db.execute(
         select(LibraryFile.id).where(
-            (LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%"))
+            (LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%")),
+            LibraryFile.deleted_at.is_(None),
         )
     )
     already_imported = [row[0] for row in existing_q.all()]
@@ -317,7 +318,7 @@ async def import_instance(
 
     # Dedupe check upfront so we don't burn bandwidth re-downloading.
     if source_url:
-        existing_q = await db.execute(select(LibraryFile).where(LibraryFile.source_url == source_url).limit(1))
+        existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
         existing_row = existing_q.scalar_one_or_none()
         if existing_row is not None:
             await service.close()
@@ -375,7 +376,7 @@ async def recent_imports(
     _ = current_user  # permission gate only
     capped = max(1, min(50, int(limit)))
     result = await db.execute(
-        select(LibraryFile)
+        LibraryFile.active()
         .where(LibraryFile.source_type == _SOURCE_TYPE)
         .order_by(LibraryFile.created_at.desc())
         .limit(capped)

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

@@ -383,7 +383,7 @@ async def add_to_queue(
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
     if data.library_file_id:
-        result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
+        result = await db.execute(LibraryFile.active().where(LibraryFile.id == data.library_file_id))
         library_file = result.scalar_one_or_none()
         if not library_file:
             raise HTTPException(400, "Library file not found")

+ 1 - 1
backend/app/api/routes/projects.py

@@ -1414,7 +1414,7 @@ async def export_project(
     for folder in linked_folders:
         # Get files in this folder
         files_result = await db.execute(
-            select(LibraryFile).where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
+            LibraryFile.active().where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
         )
         files = files_result.scalars().all()
 

+ 6 - 1
backend/app/api/routes/users.py

@@ -325,7 +325,12 @@ async def get_user_items_count(
     queue_items_count = queue_result.scalar() or 0
 
     # Count library files
-    library_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.created_by_id == user_id))
+    library_result = await db.execute(
+        select(func.count(LibraryFile.id)).where(
+            LibraryFile.created_by_id == user_id,
+            LibraryFile.deleted_at.is_(None),
+        )
+    )
     library_files_count = library_result.scalar() or 0
 
     return {

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

@@ -1505,6 +1505,23 @@ async def run_migrations(conn):
         "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
     )
 
+    # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
+    # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
+    # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.
+    #
+    # ``DATETIME`` is a SQLite-only type alias — PostgreSQL rejects it as
+    # invalid syntax, _safe_execute swallows the error, and the column is
+    # never added (breaking every query that references it). Emit
+    # dialect-appropriate SQL so both backends get the column.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at TIMESTAMP")
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_library_files_deleted_at ON library_files(deleted_at)",
+    )
+
     # Seed default settings keys that must exist on fresh install
     default_settings = [
         ("advanced_auth_enabled", "false"),

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

@@ -50,6 +50,10 @@ class Permission(StrEnum):
     LIBRARY_UPDATE_ALL = "library:update_all"
     LIBRARY_DELETE_OWN = "library:delete_own"
     LIBRARY_DELETE_ALL = "library:delete_all"
+    # Admin-only: bulk purge of old files + trash retention settings (#1008).
+    # Routine per-user trash management (restore-own, hard-delete-own) is
+    # gated by the existing LIBRARY_DELETE_* permissions instead.
+    LIBRARY_PURGE = "library:purge"
 
     # Projects
     PROJECTS_READ = "projects:read"
@@ -202,6 +206,7 @@ PERMISSION_CATEGORIES = {
         Permission.LIBRARY_UPDATE_ALL,
         Permission.LIBRARY_DELETE_OWN,
         Permission.LIBRARY_DELETE_ALL,
+        Permission.LIBRARY_PURGE,
     ],
     "Projects": [
         Permission.PROJECTS_READ,

+ 7 - 0
backend/app/main.py

@@ -30,6 +30,7 @@ from backend.app.api.routes import (
     inventory,
     kprofiles,
     library,
+    library_trash,
     local_backup,
     local_presets,
     maintenance,
@@ -77,6 +78,7 @@ from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
@@ -4195,6 +4197,9 @@ async def lifespan(app: FastAPI):
     await local_backup_service.start_scheduler()
     await obico_detection_service.start()
 
+    # Start the library trash sweeper (#1008)
+    await library_trash_service.start_scheduler()
+
     # Start AMS history recording
     start_ams_history_recording()
 
@@ -4233,6 +4238,7 @@ async def lifespan(app: FastAPI):
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
+    library_trash_service.stop_scheduler()
     obico_detection_service.stop()
     stop_ams_history_recording()
     stop_runtime_tracking()
@@ -4537,6 +4543,7 @@ app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.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_trash.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(webhook.router, prefix=app_settings.api_prefix)

+ 17 - 1
backend/app/models/library.py

@@ -2,7 +2,7 @@
 
 from datetime import datetime
 
-from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Select, String, Text, func, select
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -93,6 +93,11 @@ class LibraryFile(Base):
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 
+    # Soft-delete / trash bin (Issue #1008). When non-null, the file is in the
+    # trash and should not appear in normal listings. A background sweeper
+    # hard-deletes rows whose deleted_at is older than the retention window.
+    deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -102,6 +107,17 @@ class LibraryFile(Base):
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
 
+    @classmethod
+    def active(cls) -> "Select[tuple[LibraryFile]]":
+        """Select statement that excludes trashed (soft-deleted) files.
+
+        Use this in place of ``select(LibraryFile)`` for any user-facing listing
+        or lookup so trashed files don't leak into normal flows. Endpoints that
+        specifically operate on trashed rows (trash list, restore, sweeper)
+        must use ``select(LibraryFile)`` directly.
+        """
+        return select(cls).where(cls.deleted_at.is_(None))
+
 
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811

+ 59 - 0
backend/app/schemas/library_trash.py

@@ -0,0 +1,59 @@
+"""Schemas for the library trash bin + bulk purge (#1008)."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class PurgePreviewRequest(BaseModel):
+    older_than_days: int = Field(ge=1, le=3650, description="Age threshold in days.")
+    include_never_printed: bool = True
+
+
+class PurgePreviewResponse(BaseModel):
+    count: int
+    total_bytes: int
+    sample_filenames: list[str]
+    older_than_days: int
+    include_never_printed: bool
+
+
+class PurgeRequest(BaseModel):
+    older_than_days: int = Field(ge=1, le=3650)
+    include_never_printed: bool = True
+
+
+class PurgeResponse(BaseModel):
+    moved_to_trash: int
+
+
+class TrashFile(BaseModel):
+    id: int
+    filename: str
+    file_size: int
+    thumbnail_path: str | None = None
+    folder_id: int | None = None
+    folder_name: str | None = None
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+    deleted_at: datetime
+    auto_purge_at: datetime
+
+
+class TrashListResponse(BaseModel):
+    items: list[TrashFile]
+    total: int
+    retention_days: int
+
+
+class TrashSettings(BaseModel):
+    retention_days: int = Field(ge=1, le=365)
+    auto_purge_enabled: bool = False
+    auto_purge_days: int = Field(default=90, ge=7, le=3650)
+    auto_purge_include_never_printed: bool = True
+
+
+class EmptyTrashResponse(BaseModel):
+    deleted: int

+ 1 - 1
backend/app/services/background_dispatch.py

@@ -702,7 +702,7 @@ class BackgroundDispatchService:
         from backend.app.main import register_expected_print
 
         async with async_session() as db:
-            lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == job.source_id))
+            lib_file = await db.scalar(LibraryFile.active().where(LibraryFile.id == job.source_id))
             if not lib_file:
                 raise RuntimeError("File not found")
 

+ 388 - 0
backend/app/services/library_trash.py

@@ -0,0 +1,388 @@
+"""Library trash sweeper + purge service (#1008).
+
+Two-stage file deletion for the library:
+
+1. Users / admins soft-delete files — the row stays in ``library_files`` with
+   ``deleted_at`` stamped; the bytes stay on disk. This is handled inline in
+   ``backend.app.api.routes.library`` and exposed to admins as a bulk "purge
+   old files" operation via :meth:`LibraryTrashService.purge_older_than`.
+
+2. A background sweeper in this service hard-deletes rows (and their bytes)
+   whose ``deleted_at`` is older than the configured retention window.
+
+External files (``is_external=True``) are never placed in the trash — their
+bytes live outside Bambuddy's control, so there's nothing to restore.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from sqlalchemy import and_, delete, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import async_session
+from backend.app.models.library import LibraryFile
+from backend.app.models.settings import Settings
+
+logger = logging.getLogger(__name__)
+
+# Settings key used to persist the trash retention window (days). The sweeper
+# reads this on every tick so the UI can change it without a restart.
+TRASH_RETENTION_KEY = "library_trash_retention_days"
+DEFAULT_RETENTION_DAYS = 30
+# Clamp retention to a sensible range. 1 day is a reasonable floor (anything
+# shorter just makes trash into hard-delete); 365 gives admins plenty of rope
+# without letting accidental typos (99999) grow the table unboundedly.
+MIN_RETENTION_DAYS = 1
+MAX_RETENTION_DAYS = 365
+
+# Auto-purge settings (#1008 follow-up). When enabled, the sweeper loop also
+# runs the admin bulk purge once per 24h using the saved age threshold.
+# Default-off so existing installs don't surprise users — opt-in via Settings.
+AUTO_PURGE_ENABLED_KEY = "library_auto_purge_enabled"
+AUTO_PURGE_DAYS_KEY = "library_auto_purge_days"
+AUTO_PURGE_INCLUDE_NEVER_PRINTED_KEY = "library_auto_purge_include_never_printed"
+AUTO_PURGE_LAST_RUN_KEY = "library_auto_purge_last_run"
+DEFAULT_AUTO_PURGE_DAYS = 90
+MIN_AUTO_PURGE_DAYS = 7  # anything shorter is begging for accidents
+MAX_AUTO_PURGE_DAYS = 3650
+
+
+def _to_absolute_path(relative_path: str | None) -> Path | None:
+    """Mirror of the routes helper so this service has no route-module import.
+
+    Accepts the legacy absolute paths that predate the relative-path migration
+    verbatim; new rows always store paths relative to ``base_dir``.
+    """
+    if not relative_path:
+        return None
+    path = Path(relative_path)
+    if path.is_absolute():
+        return path
+    return Path(app_settings.base_dir) / path
+
+
+def _age_cutoff(now: datetime, older_than_days: int) -> datetime:
+    return now - timedelta(days=older_than_days)
+
+
+def _purge_filter(cutoff: datetime, include_never_printed: bool):
+    """SQLAlchemy clause selecting files eligible for admin purge.
+
+    A file is "old" if either (a) ``last_printed_at`` is set and predates the
+    cutoff, or (b) ``last_printed_at`` is NULL *and* the file was uploaded
+    before the cutoff — but only when ``include_never_printed`` is True.
+    """
+    last_printed_old = and_(
+        LibraryFile.last_printed_at.isnot(None),
+        LibraryFile.last_printed_at < cutoff,
+    )
+    if include_never_printed:
+        never_printed_old = and_(
+            LibraryFile.last_printed_at.is_(None),
+            LibraryFile.created_at < cutoff,
+        )
+        age_clause = or_(last_printed_old, never_printed_old)
+    else:
+        age_clause = last_printed_old
+    return and_(
+        LibraryFile.deleted_at.is_(None),
+        LibraryFile.is_external.is_(False),
+        age_clause,
+    )
+
+
+class LibraryTrashService:
+    """Manages the trash retention sweeper and admin-triggered bulk purges."""
+
+    def __init__(self):
+        self._scheduler_task: asyncio.Task | None = None
+        # Tick every 15 minutes — the window is a day, so this is plenty
+        # responsive without burning CPU.
+        self._check_interval = 900
+
+    async def start_scheduler(self):
+        """Start the background sweeper task (idempotent)."""
+        if self._scheduler_task is not None:
+            return
+        logger.info("Starting library trash 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 library trash sweeper")
+
+    async def _scheduler_loop(self):
+        while True:
+            try:
+                await asyncio.sleep(self._check_interval)
+                async with async_session() as db:
+                    await self._sweep(db)
+                    await self._maybe_run_auto_purge(db)
+            except asyncio.CancelledError:
+                break
+            except Exception as e:  # pragma: no cover - defensive
+                logger.error("Error in library trash sweeper: %s", e)
+                await asyncio.sleep(60)
+
+    # ---- Settings -----------------------------------------------------
+
+    async def get_retention_days(self, db: AsyncSession | None = None) -> int:
+        if db is None:
+            async with async_session() as session:
+                return await self._read_retention(session)
+        return await self._read_retention(db)
+
+    @staticmethod
+    async def _read_retention(db: AsyncSession) -> int:
+        result = await db.execute(select(Settings.value).where(Settings.key == TRASH_RETENTION_KEY))
+        raw = result.scalar_one_or_none()
+        if raw is None:
+            return DEFAULT_RETENTION_DAYS
+        try:
+            days = int(raw)
+        except (TypeError, ValueError):
+            return DEFAULT_RETENTION_DAYS
+        return max(MIN_RETENTION_DAYS, min(MAX_RETENTION_DAYS, days))
+
+    async def set_retention_days(self, db: AsyncSession, days: int) -> int:
+        """Persist the retention window. Clamped to [MIN, MAX]."""
+        clamped = max(MIN_RETENTION_DAYS, min(MAX_RETENTION_DAYS, int(days)))
+        result = await db.execute(select(Settings).where(Settings.key == TRASH_RETENTION_KEY))
+        row = result.scalar_one_or_none()
+        if row is None:
+            db.add(Settings(key=TRASH_RETENTION_KEY, value=str(clamped)))
+        else:
+            row.value = str(clamped)
+        await db.commit()
+        return clamped
+
+    @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_auto_purge_settings(self, db: AsyncSession) -> dict:
+        """Return the current auto-purge config.
+
+        Returns a dict with ``enabled`` (bool), ``days`` (int, clamped) and
+        ``include_never_printed`` (bool). Missing keys default to disabled /
+        90 days / include-never-printed-on, matching the manual purge UX.
+        """
+        enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
+        days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
+        incl_raw = await self._read_setting(db, AUTO_PURGE_INCLUDE_NEVER_PRINTED_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))
+        include_never_printed = (incl_raw or "true").lower() == "true"
+        return {
+            "enabled": enabled,
+            "days": days,
+            "include_never_printed": include_never_printed,
+        }
+
+    async def set_auto_purge_settings(
+        self,
+        db: AsyncSession,
+        *,
+        enabled: bool,
+        days: int,
+        include_never_printed: bool,
+    ) -> dict:
+        """Persist auto-purge config; returns the saved (clamped) values."""
+        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 self._write_setting(
+            db,
+            AUTO_PURGE_INCLUDE_NEVER_PRINTED_KEY,
+            "true" if include_never_printed else "false",
+        )
+        await db.commit()
+        return {
+            "enabled": enabled,
+            "days": clamped_days,
+            "include_never_printed": include_never_printed,
+        }
+
+    async def _get_last_auto_purge_run(self, db: AsyncSession) -> datetime | None:
+        raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
+        if not raw:
+            return None
+        try:
+            # Stored as ISO 8601 UTC; tolerate both with and without 'Z' suffix.
+            return datetime.fromisoformat(raw.replace("Z", "+00:00"))
+        except ValueError:
+            return None
+
+    async def _stamp_last_auto_purge_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:
+        """If auto-purge is enabled and >=24h has elapsed since the last run, run it.
+
+        Returns the number of files moved to trash (0 if disabled or throttled).
+        The 24h throttle means a 15-minute sweeper cadence still only triggers
+        one actual purge per day, keeping the DB churn predictable.
+        """
+        cfg = await self.get_auto_purge_settings(db)
+        if not cfg["enabled"]:
+            return 0
+
+        now = datetime.now(timezone.utc)
+        last = await self._get_last_auto_purge_run(db)
+        if last is not None and (now - last) < timedelta(hours=24):
+            return 0
+
+        moved = await self.purge_older_than(
+            db,
+            older_than_days=cfg["days"],
+            include_never_printed=cfg["include_never_printed"],
+        )
+        await self._stamp_last_auto_purge_run(db, now)
+        if moved:
+            logger.info("Library auto-purge: moved %d file(s) to trash (threshold=%d days)", moved, cfg["days"])
+        return moved
+
+    # ---- Preview / purge ---------------------------------------------
+
+    async def preview_purge(
+        self,
+        db: AsyncSession,
+        older_than_days: int,
+        include_never_printed: bool = True,
+        sample_limit: int = 5,
+    ) -> dict:
+        """Count + size of files eligible for purge. Reads only; never mutates."""
+        if older_than_days < 1:
+            return {"count": 0, "total_bytes": 0, "sample_filenames": []}
+        now = datetime.now(timezone.utc)
+        cutoff = _age_cutoff(now, older_than_days)
+        clause = _purge_filter(cutoff, include_never_printed)
+
+        count_result = await db.execute(select(func.count(LibraryFile.id)).where(clause))
+        count = int(count_result.scalar() or 0)
+
+        size_result = await db.execute(select(func.coalesce(func.sum(LibraryFile.file_size), 0)).where(clause))
+        total_bytes = int(size_result.scalar() or 0)
+
+        sample_result = await db.execute(
+            select(LibraryFile.filename).where(clause).order_by(LibraryFile.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,
+            "include_never_printed": include_never_printed,
+        }
+
+    async def purge_older_than(
+        self,
+        db: AsyncSession,
+        older_than_days: int,
+        include_never_printed: bool = True,
+    ) -> int:
+        """Move matching files to trash (stamps ``deleted_at``). Returns count."""
+        if older_than_days < 1:
+            return 0
+        now = datetime.now(timezone.utc)
+        cutoff = _age_cutoff(now, older_than_days)
+        clause = _purge_filter(cutoff, include_never_printed)
+
+        # We need the IDs so callers can audit or display them if they want.
+        # Doing a single UPDATE ... WHERE is safe even under concurrent
+        # uploads — the clause already excludes rows with deleted_at set.
+        id_result = await db.execute(select(LibraryFile.id).where(clause))
+        ids = [row[0] for row in id_result.all()]
+        if not ids:
+            return 0
+
+        await db.execute(LibraryFile.__table__.update().where(LibraryFile.id.in_(ids)).values(deleted_at=now))
+        await db.commit()
+        logger.info("Library purge: moved %d file(s) to trash (older_than_days=%d)", len(ids), older_than_days)
+        return len(ids)
+
+    # ---- Sweeper ------------------------------------------------------
+
+    async def _sweep(self, db: AsyncSession) -> int:
+        """Hard-delete trashed rows whose retention window has elapsed."""
+        retention = await self._read_retention(db)
+        now = datetime.now(timezone.utc)
+        cutoff = now - timedelta(days=retention)
+
+        result = await db.execute(
+            select(LibraryFile).where(
+                LibraryFile.deleted_at.isnot(None),
+                LibraryFile.deleted_at < cutoff,
+            )
+        )
+        rows = result.scalars().all()
+        if not rows:
+            return 0
+
+        deleted = 0
+        for row in rows:
+            self._unlink_on_disk(row)
+            deleted += 1
+        # Single DELETE is faster than N await db.delete() round-trips; we
+        # still need the Python loop above to unlink bytes on disk.
+        await db.execute(delete(LibraryFile).where(LibraryFile.id.in_([r.id for r in rows])))
+        await db.commit()
+        logger.info("Library trash sweeper: hard-deleted %d row(s) past %d-day retention", deleted, retention)
+        return deleted
+
+    @staticmethod
+    def _unlink_on_disk(row: LibraryFile) -> None:
+        """Best-effort cleanup of the file + thumbnail on disk."""
+        for rel in (row.file_path, row.thumbnail_path):
+            abs_path = _to_absolute_path(rel)
+            if abs_path is None:
+                continue
+            try:
+                if abs_path.exists():
+                    abs_path.unlink()
+            except OSError as e:
+                logger.warning("Trash sweep: failed to unlink %s: %s", abs_path, e)
+
+    # ---- User-facing trash ops ----------------------------------------
+
+    async def restore(self, db: AsyncSession, file: LibraryFile) -> LibraryFile:
+        """Clear ``deleted_at`` so the file reappears in listings."""
+        file.deleted_at = None
+        await db.commit()
+        await db.refresh(file)
+        return file
+
+    async def hard_delete_now(self, db: AsyncSession, file: LibraryFile) -> None:
+        """Bypass retention and delete this trashed file + its bytes immediately."""
+        self._unlink_on_disk(file)
+        await db.delete(file)
+        await db.commit()
+
+
+library_trash_service = LibraryTrashService()

+ 3 - 3
backend/app/services/print_scheduler.py

@@ -806,7 +806,7 @@ class PrintScheduler:
             if archive:
                 file_path = settings.base_dir / archive.file_path
         elif item.library_file_id:
-            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            result = await db.execute(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
             library_file = result.scalar_one_or_none()
             if library_file:
                 lib_path = Path(library_file.file_path)
@@ -1565,7 +1565,7 @@ class PrintScheduler:
             if archive:
                 return archive.filename.replace(".gcode.3mf", "").replace(".3mf", "")
         if item.library_file_id:
-            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            result = await db.execute(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
             library_file = result.scalar_one_or_none()
             if library_file:
                 return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
@@ -1631,7 +1631,7 @@ class PrintScheduler:
 
         elif item.library_file_id:
             # Print from library file (file manager)
-            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            result = await db.execute(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
             library_file = result.scalar_one_or_none()
             if not library_file:
                 item.status = "failed"

+ 2 - 2
backend/app/services/usage_tracker.py

@@ -611,7 +611,7 @@ async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
     # 1. Try library files matching the name (match base name at file boundary)
     try:
         lib_result = await db.execute(
-            select(LibraryFile)
+            LibraryFile.active()
             .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .order_by(LibraryFile.created_at.desc())
@@ -679,7 +679,7 @@ async def _find_3mf_by_filename(
     # 1. Try library files matching the name
     try:
         lib_result = await db.execute(
-            select(LibraryFile)
+            LibraryFile.active()
             .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .order_by(LibraryFile.created_at.desc())

+ 417 - 0
backend/tests/integration/test_library_trash_api.py

@@ -0,0 +1,417 @@
+"""Integration tests for the library trash bin + admin purge (#1008)."""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def file_factory(db_session):
+    """Factory for LibraryFile rows with sensible defaults."""
+    _counter = [0]
+
+    async def _create_file(**kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        counter = _counter[0]
+        defaults = {
+            "filename": f"trash_test_{counter}.3mf",
+            "file_path": f"/tmp/trash_test_{counter}.3mf",
+            "file_size": 1024 * counter,
+            "file_type": "3mf",
+        }
+        defaults.update(kwargs)
+        lib_file = LibraryFile(**defaults)
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+        return lib_file
+
+    return _create_file
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_delete_file_moves_to_trash(async_client: AsyncClient, file_factory, db_session):
+    """DELETE /library/files/{id} soft-deletes (managed) files into trash."""
+    from backend.app.models.library import LibraryFile
+
+    f = await file_factory()
+    response = await async_client.delete(f"/api/v1/library/files/{f.id}")
+    assert response.status_code == 200
+    body = response.json()
+    assert body["trashed"] is True
+
+    # Row still exists with deleted_at stamped
+    await db_session.refresh(f)
+    assert f.deleted_at is not None
+
+    # Normal listing hides it
+    list_resp = await async_client.get("/api/v1/library/files")
+    assert list_resp.status_code == 200
+    ids = [row["id"] for row in list_resp.json()]
+    assert f.id not in ids
+
+    # Trash listing surfaces it
+    trash_resp = await async_client.get("/api/v1/library/trash")
+    assert trash_resp.status_code == 200
+    payload = trash_resp.json()
+    trashed_ids = [item["id"] for item in payload["items"]]
+    assert f.id in trashed_ids
+    assert payload["total"] >= 1
+    assert payload["retention_days"] >= 1
+
+    # Row's file_type is preserved in the original table (sanity check on the filter)
+    row = await db_session.get(LibraryFile, f.id)
+    assert row is not None
+    assert row.file_type == "3mf"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_delete_external_file_hard_deletes(async_client: AsyncClient, file_factory, db_session):
+    """External files skip the trash — DB row is dropped directly."""
+    from backend.app.models.library import LibraryFile
+
+    f = await file_factory(is_external=True)
+    file_id = f.id
+    response = await async_client.delete(f"/api/v1/library/files/{file_id}")
+    assert response.status_code == 200
+    assert response.json()["trashed"] is False
+
+    # The route commits in its own session; expire ours so get() re-reads.
+    db_session.expire_all()
+    missing = await db_session.get(LibraryFile, file_id)
+    assert missing is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_restore_from_trash(async_client: AsyncClient, file_factory, db_session):
+    """Restoring a trashed file clears deleted_at and makes it visible again."""
+    f = await file_factory()
+    await async_client.delete(f"/api/v1/library/files/{f.id}")
+
+    resp = await async_client.post(f"/api/v1/library/trash/{f.id}/restore")
+    assert resp.status_code == 200
+
+    await db_session.refresh(f)
+    assert f.deleted_at is None
+
+    list_resp = await async_client.get("/api/v1/library/files")
+    ids = [row["id"] for row in list_resp.json()]
+    assert f.id in ids
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_hard_delete_from_trash(async_client: AsyncClient, file_factory, db_session):
+    """Hard-delete from trash removes the DB row immediately."""
+    from backend.app.models.library import LibraryFile
+
+    f = await file_factory()
+    file_id = f.id
+    await async_client.delete(f"/api/v1/library/files/{file_id}")
+
+    resp = await async_client.delete(f"/api/v1/library/trash/{file_id}")
+    assert resp.status_code == 200
+
+    db_session.expire_all()
+    row = await db_session.get(LibraryFile, file_id)
+    assert row is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_empty_trash(async_client: AsyncClient, file_factory, db_session):
+    """Empty-trash hard-deletes every trashed row in the caller's scope."""
+    from sqlalchemy import func, select
+
+    from backend.app.models.library import LibraryFile
+
+    for _ in range(3):
+        f = await file_factory()
+        await async_client.delete(f"/api/v1/library/files/{f.id}")
+
+    resp = await async_client.delete("/api/v1/library/trash")
+    assert resp.status_code == 200
+    assert resp.json()["deleted"] >= 3
+
+    count = await db_session.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.isnot(None)))
+    assert (count.scalar() or 0) == 0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_hard_delete_rejects_active_file(async_client: AsyncClient, file_factory):
+    """Trash endpoints 404 for files that aren't actually trashed."""
+    f = await file_factory()
+    resp = await async_client.delete(f"/api/v1/library/trash/{f.id}")
+    assert resp.status_code == 404
+
+    resp = await async_client.post(f"/api/v1/library/trash/{f.id}/restore")
+    assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_purge_preview_counts_old_files(async_client: AsyncClient, file_factory, db_session):
+    """Preview counts only files past the threshold and returns total size + samples."""
+    old_cutoff = datetime.now(timezone.utc) - timedelta(days=120)
+
+    old1 = await file_factory(file_size=5000)
+    old2 = await file_factory(file_size=7000)
+    # A young file whose created_at stays near "now" — must not be counted.
+    await file_factory(file_size=3000)
+
+    # Stamp created_at into the past so the never-printed branch matches.
+    for row, ts in ((old1, old_cutoff), (old2, old_cutoff)):
+        row.created_at = ts
+    await db_session.commit()
+
+    resp = await async_client.get(
+        "/api/v1/library/purge/preview",
+        params={"older_than_days": 90, "include_never_printed": True},
+    )
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["count"] == 2
+    assert body["total_bytes"] == 12000
+    assert body["older_than_days"] == 90
+    assert len(body["sample_filenames"]) == 2
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_purge_excludes_never_printed_when_requested(async_client: AsyncClient, file_factory, db_session):
+    """With include_never_printed=False, only files with last_printed_at are eligible."""
+    long_ago = datetime.now(timezone.utc) - timedelta(days=200)
+
+    recently_printed = await file_factory()
+    recently_printed.last_printed_at = long_ago
+    never_printed = await file_factory()
+    never_printed.created_at = long_ago
+    await db_session.commit()
+
+    # Exclude never-printed → only 1 match
+    resp = await async_client.get(
+        "/api/v1/library/purge/preview",
+        params={"older_than_days": 90, "include_never_printed": False},
+    )
+    assert resp.json()["count"] == 1
+
+    # Include → 2 matches
+    resp = await async_client.get(
+        "/api/v1/library/purge/preview",
+        params={"older_than_days": 90, "include_never_printed": True},
+    )
+    assert resp.json()["count"] == 2
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_purge_execute_moves_to_trash(async_client: AsyncClient, file_factory, db_session):
+    """POST /library/purge moves matching files into trash (deleted_at stamped)."""
+    long_ago = datetime.now(timezone.utc) - timedelta(days=200)
+    f = await file_factory()
+    f.created_at = long_ago
+    await db_session.commit()
+
+    resp = await async_client.post(
+        "/api/v1/library/purge",
+        json={"older_than_days": 90, "include_never_printed": True},
+    )
+    assert resp.status_code == 200
+    assert resp.json()["moved_to_trash"] >= 1
+
+    await db_session.refresh(f)
+    assert f.deleted_at is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_purge_skips_external_files(async_client: AsyncClient, file_factory, db_session):
+    """External files are never eligible for purge, regardless of age."""
+    long_ago = datetime.now(timezone.utc) - timedelta(days=300)
+    ext = await file_factory(is_external=True)
+    ext.created_at = long_ago
+    await db_session.commit()
+
+    resp = await async_client.get(
+        "/api/v1/library/purge/preview",
+        params={"older_than_days": 90, "include_never_printed": True},
+    )
+    assert resp.json()["count"] == 0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_trash_settings_roundtrip(async_client: AsyncClient):
+    """Retention setting persists and is clamped to [MIN, MAX]."""
+    resp = await async_client.get("/api/v1/library/trash/settings")
+    assert resp.status_code == 200
+    default = resp.json()["retention_days"]
+    assert 1 <= default <= 365
+
+    resp = await async_client.put("/api/v1/library/trash/settings", json={"retention_days": 60})
+    assert resp.status_code == 200
+    assert resp.json()["retention_days"] == 60
+
+    resp = await async_client.get("/api/v1/library/trash/settings")
+    assert resp.json()["retention_days"] == 60
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_trash_settings_rejects_out_of_range(async_client: AsyncClient):
+    """retention_days must fall within the clamped range."""
+    resp = await async_client.put("/api/v1/library/trash/settings", json={"retention_days": 0})
+    assert resp.status_code == 422  # Pydantic ge=1 trip
+
+    resp = await async_client.put("/api/v1/library/trash/settings", json={"retention_days": 9999})
+    assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_sweeper_hard_deletes_past_retention(db_session):
+    """The background sweeper clears rows whose deleted_at is older than retention."""
+    from backend.app.models.library import LibraryFile
+    from backend.app.services.library_trash import library_trash_service
+
+    # Retention = 30 days; stamp one row 40 days ago, one 5 days ago.
+    await library_trash_service.set_retention_days(db_session, 30)
+
+    fresh = LibraryFile(
+        filename="fresh.3mf",
+        file_path="/tmp/fresh.3mf",
+        file_size=1024,
+        file_type="3mf",
+        deleted_at=datetime.now(timezone.utc) - timedelta(days=5),
+    )
+    stale = LibraryFile(
+        filename="stale.3mf",
+        file_path="/tmp/stale.3mf",
+        file_size=2048,
+        file_type="3mf",
+        deleted_at=datetime.now(timezone.utc) - timedelta(days=40),
+    )
+    db_session.add_all([fresh, stale])
+    await db_session.commit()
+
+    stale_id = stale.id
+    fresh_id = fresh.id
+    deleted = await library_trash_service._sweep(db_session)
+    assert deleted >= 1
+
+    # The sweeper commits in its own session; expire ours so get() re-reads.
+    db_session.expire_all()
+    remaining = await db_session.get(LibraryFile, stale_id)
+    assert remaining is None
+    still_there = await db_session.get(LibraryFile, fresh_id)
+    assert still_there is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_settings_roundtrip(async_client: AsyncClient):
+    """Auto-purge fields on /library/trash/settings round-trip correctly."""
+    resp = await async_client.put(
+        "/api/v1/library/trash/settings",
+        json={
+            "retention_days": 30,
+            "auto_purge_enabled": True,
+            "auto_purge_days": 120,
+            "auto_purge_include_never_printed": False,
+        },
+    )
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["auto_purge_enabled"] is True
+    assert body["auto_purge_days"] == 120
+    assert body["auto_purge_include_never_printed"] is False
+
+    # GET surfaces the same saved values
+    resp = await async_client.get("/api/v1/library/trash/settings")
+    got = resp.json()
+    assert got["auto_purge_enabled"] is True
+    assert got["auto_purge_days"] == 120
+    assert got["auto_purge_include_never_printed"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_runs_when_enabled_and_throttles_by_24h(file_factory, db_session):
+    """The scheduler loop's auto-purge branch runs once, then the 24h throttle blocks."""
+    from backend.app.services.library_trash import library_trash_service
+
+    long_ago = datetime.now(timezone.utc) - timedelta(days=200)
+    f = await file_factory()
+    f.created_at = long_ago
+    await db_session.commit()
+
+    # Enable auto-purge with a 90-day threshold
+    await library_trash_service.set_auto_purge_settings(db_session, enabled=True, days=90, include_never_printed=True)
+
+    moved = await library_trash_service._maybe_run_auto_purge(db_session)
+    assert moved >= 1
+
+    db_session.expire_all()
+    await db_session.refresh(f)
+    assert f.deleted_at is not None
+
+    # Second invocation within 24h should be throttled — no additional rows moved.
+    long_ago2 = datetime.now(timezone.utc) - timedelta(days=200)
+    f2 = await file_factory()
+    f2.created_at = long_ago2
+    await db_session.commit()
+
+    moved_again = await library_trash_service._maybe_run_auto_purge(db_session)
+    assert moved_again == 0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_skipped_when_disabled(file_factory, db_session):
+    """If the toggle is off, old files stay put even when everything else matches."""
+    from backend.app.services.library_trash import library_trash_service
+
+    long_ago = datetime.now(timezone.utc) - timedelta(days=200)
+    f = await file_factory()
+    f.created_at = long_ago
+    await db_session.commit()
+
+    await library_trash_service.set_auto_purge_settings(db_session, enabled=False, days=90, include_never_printed=True)
+    moved = await library_trash_service._maybe_run_auto_purge(db_session)
+    assert moved == 0
+
+    db_session.expire_all()
+    await db_session.refresh(f)
+    assert f.deleted_at is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_trashed_file_hidden_from_makerworld_dedupe(async_client: AsyncClient, file_factory, db_session):
+    """MakerWorld 'already imported' dedupe must not match trashed rows."""
+    from sqlalchemy import select
+
+    from backend.app.models.library import LibraryFile
+
+    f = await file_factory(source_type="makerworld", source_url="https://makerworld.com/en/models/99#profileId-1")
+    # Trash it.
+    await async_client.delete(f"/api/v1/library/files/{f.id}")
+
+    # The dedupe query used by the makerworld helper is `source_url == X AND deleted_at IS NULL`.
+    result = await db_session.execute(
+        LibraryFile.active().where(LibraryFile.source_url == "https://makerworld.com/en/models/99#profileId-1")
+    )
+    assert result.scalar_one_or_none() is None
+
+    # Direct lookup WITHOUT the active filter still sees the row.
+    direct = await db_session.execute(
+        select(LibraryFile).where(LibraryFile.source_url == "https://makerworld.com/en/models/99#profileId-1")
+    )
+    assert direct.scalar_one_or_none() is not None

+ 2 - 0
frontend/src/App.tsx

@@ -12,6 +12,7 @@ import { MaintenancePage } from './pages/MaintenancePage';
 import { ProjectsPage } from './pages/ProjectsPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { FileManagerPage } from './pages/FileManagerPage';
+import { LibraryTrashPage } from './pages/LibraryTrashPage';
 import { CameraPage } from './pages/CameraPage';
 import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
@@ -193,6 +194,7 @@ function App() {
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
+                  <Route path="files/trash" element={<LibraryTrashPage />} />
                   <Route path="makerworld" element={<MakerworldPage />} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />

+ 89 - 0
frontend/src/__tests__/components/PurgeOldFilesModal.test.tsx

@@ -0,0 +1,89 @@
+/**
+ * Tests for the admin Purge Old Files modal (#1008).
+ *
+ * Covers: preview round-trip populates count + size + sample filenames,
+ * confirm button stays disabled until preview returns count > 0, confirm
+ * round-trip posts the correct payload and closes the modal.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PurgeOldFilesModal } from '../../components/PurgeOldFilesModal';
+
+afterEach(() => server.resetHandlers());
+
+describe('PurgeOldFilesModal', () => {
+  it('displays preview counts and sample filenames returned by the backend', async () => {
+    server.use(
+      http.get('*/library/purge/preview', () =>
+        HttpResponse.json({
+          count: 3,
+          total_bytes: 5_242_880,
+          sample_filenames: ['a.3mf', 'b.3mf', 'c.3mf'],
+          older_than_days: 90,
+          include_never_printed: true,
+        }),
+      ),
+    );
+
+    render(<PurgeOldFilesModal onClose={vi.fn()} />);
+
+    await screen.findByText(/3 files/i);
+    expect(screen.getByText('a.3mf')).toBeInTheDocument();
+    expect(screen.getByText('b.3mf')).toBeInTheDocument();
+    expect(screen.getByText('c.3mf')).toBeInTheDocument();
+  });
+
+  it('leaves the confirm button disabled when the preview returns zero matches', async () => {
+    server.use(
+      http.get('*/library/purge/preview', () =>
+        HttpResponse.json({
+          count: 0,
+          total_bytes: 0,
+          sample_filenames: [],
+          older_than_days: 90,
+          include_never_printed: true,
+        }),
+      ),
+    );
+
+    render(<PurgeOldFilesModal onClose={vi.fn()} />);
+    await screen.findByText(/0 files/i);
+
+    // The confirm button label contains the count; it should be disabled.
+    const confirm = screen.getByRole('button', { name: /Move 0 to trash/i });
+    expect(confirm).toBeDisabled();
+  });
+
+  it('invokes the purge endpoint and closes the modal on confirm', async () => {
+    let purgeBody: { older_than_days: number; include_never_printed: boolean } | null = null;
+    server.use(
+      http.get('*/library/purge/preview', () =>
+        HttpResponse.json({
+          count: 2,
+          total_bytes: 1024,
+          sample_filenames: ['x.3mf', 'y.3mf'],
+          older_than_days: 90,
+          include_never_printed: true,
+        }),
+      ),
+      http.post('*/library/purge', async ({ request }) => {
+        purgeBody = (await request.json()) as typeof purgeBody;
+        return HttpResponse.json({ moved_to_trash: 2 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    render(<PurgeOldFilesModal onClose={onClose} />);
+
+    await screen.findByText(/2 files/i);
+    await userEvent.click(screen.getByRole('button', { name: /Move 2 to trash/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(purgeBody).toEqual({ older_than_days: 90, include_never_printed: true });
+  });
+});

+ 150 - 0
frontend/src/__tests__/pages/LibraryTrashPage.test.tsx

@@ -0,0 +1,150 @@
+/**
+ * Tests for the Library Trash page (#1008).
+ *
+ * Covers: empty-trash view, populated table with file/folder/size columns,
+ * restore round-trip (invalidates list), purge-now with confirmation dialog,
+ * empty-trash bulk action, and the admin-only retention setting control.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { LibraryTrashPage } from '../../pages/LibraryTrashPage';
+
+function trashItem(overrides: Partial<Record<string, unknown>> = {}) {
+  return {
+    id: 1,
+    filename: 'old_benchy.3mf',
+    file_size: 204800,
+    thumbnail_path: null,
+    folder_id: null,
+    folder_name: 'Calibration',
+    created_by_id: 1,
+    created_by_username: 'alice',
+    deleted_at: '2026-04-10T12:00:00Z',
+    auto_purge_at: '2026-05-10T12:00:00Z',
+    ...overrides,
+  };
+}
+
+afterEach(() => server.resetHandlers());
+
+describe('LibraryTrashPage', () => {
+  it('shows the empty state when there are no trashed files', async () => {
+    server.use(
+      http.get('*/library/trash', () =>
+        HttpResponse.json({ items: [], total: 0, retention_days: 30 }),
+      ),
+      http.get('*/library/trash/settings', () =>
+        HttpResponse.json({ retention_days: 30 }),
+      ),
+    );
+
+    render(<LibraryTrashPage />);
+
+    expect(await screen.findByText(/trash is empty/i)).toBeInTheDocument();
+  });
+
+  it('renders trashed files and the retention control for admins', async () => {
+    server.use(
+      http.get('*/library/trash', () =>
+        HttpResponse.json({
+          items: [trashItem(), trashItem({ id: 2, filename: 'old_calibration.3mf', file_size: 102400 })],
+          total: 2,
+          retention_days: 30,
+        }),
+      ),
+      http.get('*/library/trash/settings', () =>
+        HttpResponse.json({ retention_days: 30 }),
+      ),
+    );
+
+    render(<LibraryTrashPage />);
+
+    expect(await screen.findByText('old_benchy.3mf')).toBeInTheDocument();
+    expect(screen.getByText('old_calibration.3mf')).toBeInTheDocument();
+    // Retention input is visible when auth is off (isAdmin=true in tests)
+    expect(screen.getByLabelText(/auto-delete after/i)).toBeInTheDocument();
+  });
+
+  it('restores a file when Restore is clicked', async () => {
+    let restoreCalledFor: number | null = null;
+    server.use(
+      http.get('*/library/trash', () =>
+        HttpResponse.json({ items: [trashItem()], total: 1, retention_days: 30 }),
+      ),
+      http.get('*/library/trash/settings', () =>
+        HttpResponse.json({ retention_days: 30 }),
+      ),
+      http.post('*/library/trash/:id/restore', ({ params }) => {
+        restoreCalledFor = Number(params.id);
+        return HttpResponse.json({ status: 'success', id: Number(params.id) });
+      }),
+    );
+
+    render(<LibraryTrashPage />);
+
+    await screen.findByText('old_benchy.3mf');
+    await userEvent.click(screen.getByRole('button', { name: /restore/i }));
+
+    await waitFor(() => expect(restoreCalledFor).toBe(1));
+  });
+
+  it('prompts before permanently deleting a single file', async () => {
+    let deleteCalledFor: number | null = null;
+    server.use(
+      http.get('*/library/trash', () =>
+        HttpResponse.json({ items: [trashItem()], total: 1, retention_days: 30 }),
+      ),
+      http.get('*/library/trash/settings', () =>
+        HttpResponse.json({ retention_days: 30 }),
+      ),
+      http.delete('*/library/trash/:id', ({ params }) => {
+        deleteCalledFor = Number(params.id);
+        return HttpResponse.json({ status: 'success' });
+      }),
+    );
+
+    render(<LibraryTrashPage />);
+
+    await screen.findByText('old_benchy.3mf');
+    await userEvent.click(screen.getByRole('button', { name: /delete now/i }));
+
+    // ConfirmModal opens. The modal's title is unique; the body repeats the filename.
+    expect(await screen.findByRole('heading', { name: /delete permanently/i })).toBeInTheDocument();
+
+    await userEvent.click(screen.getByRole('button', { name: /delete permanently/i }));
+    await waitFor(() => expect(deleteCalledFor).toBe(1));
+  });
+
+  it('empties the trash via the Empty Trash action', async () => {
+    let emptyCalled = false;
+    server.use(
+      http.get('*/library/trash', () =>
+        HttpResponse.json({
+          items: [trashItem(), trashItem({ id: 2, filename: 'two.3mf' })],
+          total: 2,
+          retention_days: 30,
+        }),
+      ),
+      http.get('*/library/trash/settings', () =>
+        HttpResponse.json({ retention_days: 30 }),
+      ),
+      http.delete('*/library/trash', () => {
+        emptyCalled = true;
+        return HttpResponse.json({ deleted: 2 });
+      }),
+    );
+
+    render(<LibraryTrashPage />);
+
+    await screen.findByText('old_benchy.3mf');
+    await userEvent.click(screen.getByRole('button', { name: /empty trash/i }));
+    await userEvent.click(screen.getByRole('button', { name: /delete permanently/i }));
+
+    await waitFor(() => expect(emptyCalled).toBe(true));
+  });
+});

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

@@ -2304,6 +2304,7 @@ export type Permission =
   | 'queue:reorder'
   | 'library:read' | 'library:upload'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
+  | 'library:purge'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete' | 'inventory:view_assignments'
@@ -4634,7 +4635,32 @@ export const api = {
       body: JSON.stringify(data),
     }),
   deleteLibraryFile: (id: number) =>
-    request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
+    request<{ status: string; message: string; trashed: boolean }>(`/library/files/${id}`, { method: 'DELETE' }),
+
+  // ========== Library Trash (#1008) ==========
+  previewLibraryPurge: (olderThanDays: number, includeNeverPrinted: boolean = true) =>
+    request<LibraryPurgePreview>(
+      `/library/purge/preview?older_than_days=${olderThanDays}&include_never_printed=${includeNeverPrinted}`,
+    ),
+  executeLibraryPurge: (olderThanDays: number, includeNeverPrinted: boolean = true) =>
+    request<{ moved_to_trash: number }>('/library/purge', {
+      method: 'POST',
+      body: JSON.stringify({ older_than_days: olderThanDays, include_never_printed: includeNeverPrinted }),
+    }),
+  listLibraryTrash: (limit: number = 100, offset: number = 0) =>
+    request<LibraryTrashListResponse>(`/library/trash?limit=${limit}&offset=${offset}`),
+  restoreLibraryTrash: (fileId: number) =>
+    request<{ status: string; id: number }>(`/library/trash/${fileId}/restore`, { method: 'POST' }),
+  hardDeleteLibraryTrash: (fileId: number) =>
+    request<{ status: string }>(`/library/trash/${fileId}`, { method: 'DELETE' }),
+  emptyLibraryTrash: () => request<{ deleted: number }>('/library/trash', { method: 'DELETE' }),
+  getLibraryTrashSettings: () =>
+    request<LibraryTrashSettings>('/library/trash/settings'),
+  updateLibraryTrashSettings: (body: LibraryTrashSettings) =>
+    request<LibraryTrashSettings>('/library/trash/settings', {
+      method: 'PUT',
+      body: JSON.stringify(body),
+    }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
   createLibrarySlicerToken: (fileId: number) =>
     request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
@@ -5095,6 +5121,41 @@ export interface LibraryFileUpdate {
   notes?: string | null;
 }
 
+// Library trash (#1008)
+export interface LibraryTrashItem {
+  id: number;
+  filename: string;
+  file_size: number;
+  thumbnail_path: string | null;
+  folder_id: number | null;
+  folder_name: string | null;
+  created_by_id: number | null;
+  created_by_username: string | null;
+  deleted_at: string;
+  auto_purge_at: string;
+}
+
+export interface LibraryTrashListResponse {
+  items: LibraryTrashItem[];
+  total: number;
+  retention_days: number;
+}
+
+export interface LibraryPurgePreview {
+  count: number;
+  total_bytes: number;
+  sample_filenames: string[];
+  older_than_days: number;
+  include_never_printed: boolean;
+}
+
+export interface LibraryTrashSettings {
+  retention_days: number;
+  auto_purge_enabled: boolean;
+  auto_purge_days: number;
+  auto_purge_include_never_printed: boolean;
+}
+
 export interface LibraryFileUploadResponse {
   id: number;
   filename: string;

+ 173 - 0
frontend/src/components/PurgeOldFilesModal.tsx

@@ -0,0 +1,173 @@
+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 PurgeOldFilesModalProps {
+  onClose: () => void;
+}
+
+const DEFAULT_DAYS = 90;
+
+export function PurgeOldFilesModal({ onClose }: PurgeOldFilesModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [days, setDays] = useState(DEFAULT_DAYS);
+  const [includeNeverPrinted, setIncludeNeverPrinted] = useState(true);
+
+  // Debounce the preview query so dragging a slider isn't a DoS.
+  const [debouncedDays, setDebouncedDays] = useState(days);
+  useEffect(() => {
+    const handle = window.setTimeout(() => setDebouncedDays(days), 300);
+    return () => window.clearTimeout(handle);
+  }, [days]);
+
+  const previewQuery = useQuery({
+    queryKey: ['library-purge-preview', debouncedDays, includeNeverPrinted],
+    queryFn: () => api.previewLibraryPurge(debouncedDays, includeNeverPrinted),
+    enabled: debouncedDays >= 1,
+  });
+
+  const purgeMutation = useMutation({
+    mutationFn: () => api.executeLibraryPurge(days, includeNeverPrinted),
+    onSuccess: (res) => {
+      showToast(t('libraryPurge.toast.success', { count: res.moved_to_trash }), 'success');
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
+      onClose();
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryPurge.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('libraryPurge.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('libraryPurge.description')}
+          </p>
+
+          <div>
+            <label htmlFor="purge-days" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+              {t('libraryPurge.ageLabel')}
+            </label>
+            <div className="flex items-center gap-3">
+              <input
+                id="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('libraryPurge.days')}</span>
+            </div>
+          </div>
+
+          <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
+            <input
+              type="checkbox"
+              checked={includeNeverPrinted}
+              onChange={(e) => setIncludeNeverPrinted(e.target.checked)}
+              className="rounded border-gray-300"
+            />
+            {t('libraryPurge.includeNeverPrinted')}
+          </label>
+
+          <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('libraryPurge.previewLoading')}
+              </div>
+            ) : previewQuery.isError ? (
+              <div className="text-sm text-red-600 dark:text-red-400">
+                {(previewQuery.error as Error | null)?.message ?? t('libraryPurge.previewFailed')}
+              </div>
+            ) : (
+              <div className="text-sm text-gray-900 dark:text-gray-100">
+                <div className="font-medium">
+                  {t('libraryPurge.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('libraryPurge.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('libraryPurge.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('libraryPurge.purging')}
+              </>
+            ) : (
+              t('libraryPurge.confirmCta', { count })
+            )}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -5139,4 +5139,86 @@ export default {
       deleteFailed: 'Datei konnte nicht aus der Bibliothek entfernt werden.',
     },
   },
+  libraryTrash: {
+    title: 'Papierkorb',
+    headerButton: 'Papierkorb',
+    headerTooltip: 'In den Papierkorb verschobene Dateien anzeigen',
+    backToFiles: 'Zurück zum Dateimanager',
+    subtitleAdmin: 'Gelöschte Dateien bleiben {{days}} Tage hier und werden dann automatisch entfernt. Diese Ansicht zeigt Papierkorb-Dateien aller Benutzer.',
+    subtitleUser: 'Gelöschte Dateien bleiben {{days}} Tage hier und werden dann automatisch entfernt.',
+    loading: 'Papierkorb wird geladen…',
+    loadError: 'Papierkorb konnte nicht geladen werden.',
+    empty: 'Der Papierkorb ist leer.',
+    summary: '{{count}} Dateien · {{size}}',
+    emptyTrash: 'Papierkorb leeren',
+    restore: 'Wiederherstellen',
+    purgeNow: 'Jetzt löschen',
+    autoPurgeIn: 'Wird in {{when}} gelöscht',
+    days: 'Tage',
+    retentionLabel: 'Automatisch löschen nach',
+    selectAll: 'Alle auswählen',
+    selectOne: '{{filename}} auswählen',
+    selectionCount: '{{count}} ausgewählt',
+    bulkRestore: 'Auswahl wiederherstellen',
+    bulkPurge: 'Auswahl löschen',
+    col: {
+      filename: 'Datei',
+      folder: 'Ordner',
+      size: 'Größe',
+      deleted: 'Verschoben',
+      autoPurge: 'Löschung',
+      owner: 'Besitzer',
+      actions: 'Aktionen',
+    },
+    confirm: {
+      purgeTitle: 'Endgültig löschen?',
+      purgeBody: '{{filename}} wird von der Festplatte gelöscht und kann nicht wiederhergestellt werden.',
+      emptyTitle: 'Papierkorb leeren?',
+      emptyBody: 'Alle {{count}} Dateien werden endgültig von der Festplatte gelöscht.',
+      bulkPurgeTitle: 'Ausgewählte Dateien endgültig löschen?',
+      bulkPurgeBody: 'Die {{count}} ausgewählten Dateien werden von der Festplatte gelöscht und können nicht wiederhergestellt werden.',
+      cta: 'Endgültig löschen',
+    },
+    toast: {
+      restored: 'Datei wiederhergestellt.',
+      restoreFailed: 'Datei konnte nicht wiederhergestellt werden.',
+      purged: 'Datei endgültig gelöscht.',
+      purgeFailed: 'Datei konnte nicht gelöscht werden.',
+      emptied: '{{count}} Datei(en) aus dem Papierkorb gelöscht.',
+      emptyFailed: 'Papierkorb konnte nicht geleert werden.',
+      retentionSaved: 'Automatisches Löschen auf {{days}} Tage gesetzt.',
+      retentionFailed: 'Einstellung konnte nicht gespeichert werden.',
+      bulkRestored: '{{count}} Datei(en) wiederhergestellt.',
+      bulkPurged: '{{count}} Datei(en) gelöscht.',
+    },
+  },
+  libraryPurge: {
+    title: 'Alte Dateien entfernen',
+    headerButton: 'Alte entfernen',
+    headerTooltip: 'Alte Dateien im Block in den Papierkorb verschieben',
+    description: 'Dateien, die älter als die gewählte Schwelle sind, werden in den Papierkorb verschoben. Externe Ordner werden übersprungen. Vor dem automatischen Löschen können Dateien aus dem Papierkorb wiederhergestellt werden.',
+    ageLabel: 'Dateien älter als',
+    days: 'Tage',
+    includeNeverPrinted: 'Dateien einbeziehen, die nie gedruckt wurden',
+    previewLoading: 'Prüfe, wie viele Dateien passen…',
+    previewFailed: 'Vorschau konnte nicht geladen werden.',
+    previewSummary: '{{count}} Dateien · {{size}} würden in den Papierkorb verschoben',
+    andMore: '…und {{count}} weitere',
+    warning: 'Dateien werden nur in den Papierkorb verschoben — bis zum Ablauf der Aufbewahrungsfrist kannst du sie wiederherstellen.',
+    confirmCta: '{{count}} in den Papierkorb verschieben',
+    purging: 'Wird verschoben…',
+    toast: {
+      success: '{{count}} Datei(en) in den Papierkorb verschoben.',
+      failed: 'Dateien konnten nicht verschoben werden.',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: 'Alte Dateien automatisch entfernen',
+    enableDescription: 'Führt die Admin-Bereinigung einmal pro Tag aus. Dateien landen zuerst im Papierkorb — sie werden nicht sofort gelöscht.',
+    ageLabel: 'Dateien älter als',
+    ageDescription: 'Minimum 7 Tage, Maximum 10 Jahre. Verwendet dieselbe Altersregel wie die manuelle „Alte entfernen“-Schaltfläche.',
+    days: 'Tage',
+    includeNeverPrinted: 'Dateien einbeziehen, die nie gedruckt wurden',
+    saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
+  },
 };

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

@@ -5147,4 +5147,86 @@ export default {
       deleteFailed: 'Could not remove the file from the library.',
     },
   },
+  libraryTrash: {
+    title: 'Trash',
+    headerButton: 'Trash',
+    headerTooltip: 'View files moved to the trash',
+    backToFiles: 'Back to File Manager',
+    subtitleAdmin: 'Deleted files stay here for {{days}} days, then auto-delete. This view shows trashed files for all users.',
+    subtitleUser: 'Deleted files stay here for {{days}} days, then auto-delete.',
+    loading: 'Loading trash…',
+    loadError: 'Could not load the trash.',
+    empty: 'The trash is empty.',
+    summary: '{{count}} files · {{size}}',
+    emptyTrash: 'Empty trash',
+    restore: 'Restore',
+    purgeNow: 'Delete now',
+    autoPurgeIn: 'Auto-deletes in {{when}}',
+    days: 'days',
+    retentionLabel: 'Auto-delete after',
+    selectAll: 'Select all',
+    selectOne: 'Select {{filename}}',
+    selectionCount: '{{count}} selected',
+    bulkRestore: 'Restore selected',
+    bulkPurge: 'Delete selected',
+    col: {
+      filename: 'File',
+      folder: 'Folder',
+      size: 'Size',
+      deleted: 'Moved to trash',
+      autoPurge: 'Auto-deletes',
+      owner: 'Owner',
+      actions: 'Actions',
+    },
+    confirm: {
+      purgeTitle: 'Delete permanently?',
+      purgeBody: '{{filename}} will be deleted from disk and cannot be restored.',
+      emptyTitle: 'Empty the trash?',
+      emptyBody: 'All {{count}} files will be deleted from disk. This cannot be undone.',
+      bulkPurgeTitle: 'Delete selected files permanently?',
+      bulkPurgeBody: 'The {{count}} selected files will be deleted from disk and cannot be restored.',
+      cta: 'Delete permanently',
+    },
+    toast: {
+      restored: 'File restored.',
+      restoreFailed: 'Could not restore the file.',
+      purged: 'File deleted permanently.',
+      purgeFailed: 'Could not delete the file.',
+      emptied: 'Deleted {{count}} file(s) from trash.',
+      emptyFailed: 'Could not empty the trash.',
+      retentionSaved: 'Auto-delete set to {{days}} days.',
+      retentionFailed: 'Could not save retention setting.',
+      bulkRestored: 'Restored {{count}} file(s).',
+      bulkPurged: 'Deleted {{count}} file(s).',
+    },
+  },
+  libraryPurge: {
+    title: 'Purge old files',
+    headerButton: 'Purge old',
+    headerTooltip: 'Bulk-move old files to trash',
+    description: 'Files older than the threshold will be moved to the trash. External folders are skipped. You can restore from trash before auto-deletion.',
+    ageLabel: 'Move files older than',
+    days: 'days',
+    includeNeverPrinted: 'Include files that have never been printed',
+    previewLoading: 'Checking how many files match…',
+    previewFailed: 'Could not preview the purge.',
+    previewSummary: '{{count}} files · {{size}} would move to trash',
+    andMore: '…and {{count}} more',
+    warning: 'Files are soft-deleted — you can restore them from Trash until the retention window expires.',
+    confirmCta: 'Move {{count}} to trash',
+    purging: 'Moving to trash…',
+    toast: {
+      success: 'Moved {{count}} file(s) to trash.',
+      failed: 'Could not purge files.',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: 'Auto-purge old files',
+    enableDescription: 'Runs the admin purge once per day. Files go to Trash first — they are not deleted immediately.',
+    ageLabel: 'Auto-purge files older than',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Uses the same age rule as the manual Purge button.',
+    days: 'days',
+    includeNeverPrinted: 'Include files that have never been printed',
+    saveFailed: 'Could not save auto-purge settings.',
+  },
 };

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

@@ -5052,4 +5052,86 @@ export default {
       deleteFailed: 'Impossible de retirer le fichier de la bibliothèque.',
     },
   },
+  libraryTrash: {
+    title: 'Corbeille',
+    headerButton: 'Corbeille',
+    headerTooltip: 'Voir les fichiers déplacés dans la corbeille',
+    backToFiles: 'Retour au gestionnaire de fichiers',
+    subtitleAdmin: 'Les fichiers supprimés restent ici pendant {{days}} jours, puis sont supprimés automatiquement. Cette vue affiche la corbeille de tous les utilisateurs.',
+    subtitleUser: 'Les fichiers supprimés restent ici pendant {{days}} jours, puis sont supprimés automatiquement.',
+    loading: 'Chargement de la corbeille…',
+    loadError: 'Impossible de charger la corbeille.',
+    empty: 'La corbeille est vide.',
+    summary: '{{count}} fichiers · {{size}}',
+    emptyTrash: 'Vider la corbeille',
+    restore: 'Restaurer',
+    purgeNow: 'Supprimer maintenant',
+    autoPurgeIn: 'Suppression automatique dans {{when}}',
+    days: 'jours',
+    retentionLabel: 'Supprimer automatiquement après',
+    selectAll: 'Tout sélectionner',
+    selectOne: 'Sélectionner {{filename}}',
+    selectionCount: '{{count}} sélectionné(s)',
+    bulkRestore: 'Restaurer la sélection',
+    bulkPurge: 'Supprimer la sélection',
+    col: {
+      filename: 'Fichier',
+      folder: 'Dossier',
+      size: 'Taille',
+      deleted: 'Mis à la corbeille',
+      autoPurge: 'Suppression auto.',
+      owner: 'Propriétaire',
+      actions: 'Actions',
+    },
+    confirm: {
+      purgeTitle: 'Supprimer définitivement ?',
+      purgeBody: '{{filename}} sera supprimé du disque et ne pourra pas être restauré.',
+      emptyTitle: 'Vider la corbeille ?',
+      emptyBody: 'Tous les {{count}} fichiers seront supprimés du disque. Cette action est irréversible.',
+      bulkPurgeTitle: 'Supprimer définitivement les fichiers sélectionnés ?',
+      bulkPurgeBody: 'Les {{count}} fichiers sélectionnés seront supprimés du disque et ne pourront pas être restaurés.',
+      cta: 'Supprimer définitivement',
+    },
+    toast: {
+      restored: 'Fichier restauré.',
+      restoreFailed: 'Impossible de restaurer le fichier.',
+      purged: 'Fichier supprimé définitivement.',
+      purgeFailed: 'Impossible de supprimer le fichier.',
+      emptied: '{{count}} fichier(s) supprimé(s) de la corbeille.',
+      emptyFailed: 'Impossible de vider la corbeille.',
+      retentionSaved: 'Suppression automatique réglée sur {{days}} jours.',
+      retentionFailed: 'Impossible d\'enregistrer le réglage de rétention.',
+      bulkRestored: '{{count}} fichier(s) restauré(s).',
+      bulkPurged: '{{count}} fichier(s) supprimé(s).',
+    },
+  },
+  libraryPurge: {
+    title: 'Purger les anciens fichiers',
+    headerButton: 'Purger',
+    headerTooltip: 'Déplacer en masse les anciens fichiers vers la corbeille',
+    description: 'Les fichiers plus anciens que le seuil seront déplacés vers la corbeille. Les dossiers externes sont ignorés. Vous pouvez restaurer depuis la corbeille avant la suppression automatique.',
+    ageLabel: 'Déplacer les fichiers plus anciens que',
+    days: 'jours',
+    includeNeverPrinted: 'Inclure les fichiers jamais imprimés',
+    previewLoading: 'Vérification du nombre de fichiers concernés…',
+    previewFailed: 'Impossible de prévisualiser la purge.',
+    previewSummary: '{{count}} fichiers · {{size}} seraient déplacés vers la corbeille',
+    andMore: '…et {{count}} de plus',
+    warning: 'Les fichiers sont supprimés en douceur — vous pouvez les restaurer depuis la corbeille jusqu\'à l\'expiration de la période de rétention.',
+    confirmCta: 'Déplacer {{count}} vers la corbeille',
+    purging: 'Déplacement vers la corbeille…',
+    toast: {
+      success: '{{count}} fichier(s) déplacé(s) vers la corbeille.',
+      failed: 'Impossible de purger les fichiers.',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: 'Purge automatique des anciens fichiers',
+    enableDescription: 'Exécute la purge administrateur une fois par jour. Les fichiers sont d\'abord déplacés vers la corbeille — ils ne sont pas supprimés immédiatement.',
+    ageLabel: 'Purger automatiquement les fichiers plus anciens que',
+    ageDescription: 'Minimum 7 jours, maximum 10 ans. Utilise la même règle d\'ancienneté que le bouton Purger manuel.',
+    days: 'jours',
+    includeNeverPrinted: 'Inclure les fichiers jamais imprimés',
+    saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.',
+  },
 };

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

@@ -5051,4 +5051,86 @@ export default {
       deleteFailed: 'Impossibile rimuovere il file dalla libreria.',
     },
   },
+  libraryTrash: {
+    title: 'Cestino',
+    headerButton: 'Cestino',
+    headerTooltip: 'Visualizza i file spostati nel cestino',
+    backToFiles: 'Torna al gestore file',
+    subtitleAdmin: 'I file eliminati restano qui per {{days}} giorni, poi vengono rimossi automaticamente. Questa vista mostra il cestino di tutti gli utenti.',
+    subtitleUser: 'I file eliminati restano qui per {{days}} giorni, poi vengono rimossi automaticamente.',
+    loading: 'Caricamento del cestino…',
+    loadError: 'Impossibile caricare il cestino.',
+    empty: 'Il cestino è vuoto.',
+    summary: '{{count}} file · {{size}}',
+    emptyTrash: 'Svuota cestino',
+    restore: 'Ripristina',
+    purgeNow: 'Elimina ora',
+    autoPurgeIn: 'Eliminazione automatica tra {{when}}',
+    days: 'giorni',
+    retentionLabel: 'Elimina automaticamente dopo',
+    selectAll: 'Seleziona tutto',
+    selectOne: 'Seleziona {{filename}}',
+    selectionCount: '{{count}} selezionati',
+    bulkRestore: 'Ripristina selezionati',
+    bulkPurge: 'Elimina selezionati',
+    col: {
+      filename: 'File',
+      folder: 'Cartella',
+      size: 'Dimensione',
+      deleted: 'Spostato nel cestino',
+      autoPurge: 'Eliminazione auto.',
+      owner: 'Proprietario',
+      actions: 'Azioni',
+    },
+    confirm: {
+      purgeTitle: 'Eliminare definitivamente?',
+      purgeBody: '{{filename}} verrà eliminato dal disco e non potrà essere ripristinato.',
+      emptyTitle: 'Svuotare il cestino?',
+      emptyBody: 'Tutti i {{count}} file verranno eliminati dal disco. Azione non annullabile.',
+      bulkPurgeTitle: 'Eliminare definitivamente i file selezionati?',
+      bulkPurgeBody: 'I {{count}} file selezionati verranno eliminati dal disco e non potranno essere ripristinati.',
+      cta: 'Elimina definitivamente',
+    },
+    toast: {
+      restored: 'File ripristinato.',
+      restoreFailed: 'Impossibile ripristinare il file.',
+      purged: 'File eliminato definitivamente.',
+      purgeFailed: 'Impossibile eliminare il file.',
+      emptied: '{{count}} file eliminati dal cestino.',
+      emptyFailed: 'Impossibile svuotare il cestino.',
+      retentionSaved: 'Eliminazione automatica impostata su {{days}} giorni.',
+      retentionFailed: 'Impossibile salvare l\'impostazione di conservazione.',
+      bulkRestored: '{{count}} file ripristinati.',
+      bulkPurged: '{{count}} file eliminati.',
+    },
+  },
+  libraryPurge: {
+    title: 'Elimina file vecchi',
+    headerButton: 'Elimina vecchi',
+    headerTooltip: 'Sposta in blocco i file vecchi nel cestino',
+    description: 'I file più vecchi della soglia verranno spostati nel cestino. Le cartelle esterne vengono ignorate. Puoi ripristinarli dal cestino prima dell\'eliminazione automatica.',
+    ageLabel: 'Sposta i file più vecchi di',
+    days: 'giorni',
+    includeNeverPrinted: 'Includi i file mai stampati',
+    previewLoading: 'Verifica quanti file corrispondono…',
+    previewFailed: 'Impossibile mostrare l\'anteprima.',
+    previewSummary: '{{count}} file · {{size}} verrebbero spostati nel cestino',
+    andMore: '…e altri {{count}}',
+    warning: 'I file vengono eliminati in modo soft — puoi ripristinarli dal cestino fino alla scadenza del periodo di conservazione.',
+    confirmCta: 'Sposta {{count}} nel cestino',
+    purging: 'Spostamento nel cestino…',
+    toast: {
+      success: '{{count}} file spostati nel cestino.',
+      failed: 'Impossibile eliminare i file.',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: 'Elimina automaticamente i file vecchi',
+    enableDescription: 'Esegue l\'eliminazione amministrativa una volta al giorno. I file finiscono prima nel cestino — non vengono eliminati immediatamente.',
+    ageLabel: 'Elimina automaticamente i file più vecchi di',
+    ageDescription: 'Minimo 7 giorni, massimo 10 anni. Usa la stessa regola di età del pulsante Elimina manuale.',
+    days: 'giorni',
+    includeNeverPrinted: 'Includi i file mai stampati',
+    saveFailed: 'Impossibile salvare le impostazioni di eliminazione automatica.',
+  },
 };

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

@@ -5090,4 +5090,86 @@ export default {
       deleteFailed: 'ライブラリからファイルを削除できませんでした。',
     },
   },
+  libraryTrash: {
+    title: 'ゴミ箱',
+    headerButton: 'ゴミ箱',
+    headerTooltip: 'ゴミ箱に移動したファイルを表示',
+    backToFiles: 'ファイルマネージャーに戻る',
+    subtitleAdmin: '削除されたファイルは {{days}} 日間ゴミ箱に残り、その後自動的に削除されます。このビューには全ユーザーのゴミ箱が表示されます。',
+    subtitleUser: '削除されたファイルは {{days}} 日間ゴミ箱に残り、その後自動的に削除されます。',
+    loading: 'ゴミ箱を読み込み中…',
+    loadError: 'ゴミ箱を読み込めませんでした。',
+    empty: 'ゴミ箱は空です。',
+    summary: '{{count}} 件 · {{size}}',
+    emptyTrash: 'ゴミ箱を空にする',
+    restore: '復元',
+    purgeNow: '今すぐ削除',
+    autoPurgeIn: '{{when}} に自動削除',
+    days: '日',
+    retentionLabel: '自動削除までの期間',
+    selectAll: 'すべて選択',
+    selectOne: '{{filename}} を選択',
+    selectionCount: '{{count}} 件選択中',
+    bulkRestore: '選択したものを復元',
+    bulkPurge: '選択したものを削除',
+    col: {
+      filename: 'ファイル',
+      folder: 'フォルダ',
+      size: 'サイズ',
+      deleted: 'ゴミ箱に移動',
+      autoPurge: '自動削除',
+      owner: '所有者',
+      actions: '操作',
+    },
+    confirm: {
+      purgeTitle: '完全に削除しますか?',
+      purgeBody: '{{filename}} はディスクから削除され、復元できません。',
+      emptyTitle: 'ゴミ箱を空にしますか?',
+      emptyBody: '{{count}} 件のファイルすべてがディスクから削除されます。この操作は取り消せません。',
+      bulkPurgeTitle: '選択したファイルを完全に削除しますか?',
+      bulkPurgeBody: '選択した {{count}} 件のファイルがディスクから削除され、復元できません。',
+      cta: '完全に削除',
+    },
+    toast: {
+      restored: 'ファイルを復元しました。',
+      restoreFailed: 'ファイルを復元できませんでした。',
+      purged: 'ファイルを完全に削除しました。',
+      purgeFailed: 'ファイルを削除できませんでした。',
+      emptied: 'ゴミ箱から {{count}} 件のファイルを削除しました。',
+      emptyFailed: 'ゴミ箱を空にできませんでした。',
+      retentionSaved: '自動削除を {{days}} 日に設定しました。',
+      retentionFailed: '保持期間の設定を保存できませんでした。',
+      bulkRestored: '{{count}} 件のファイルを復元しました。',
+      bulkPurged: '{{count}} 件のファイルを削除しました。',
+    },
+  },
+  libraryPurge: {
+    title: '古いファイルを一括削除',
+    headerButton: '古いファイル削除',
+    headerTooltip: '古いファイルをまとめてゴミ箱に移動',
+    description: '指定した期間より古いファイルはゴミ箱に移動されます。外部フォルダはスキップされます。自動削除までの間、ゴミ箱から復元できます。',
+    ageLabel: '次より古いファイルを移動',
+    days: '日',
+    includeNeverPrinted: '一度も印刷していないファイルも含める',
+    previewLoading: '対象ファイル数を確認中…',
+    previewFailed: 'プレビューを取得できませんでした。',
+    previewSummary: '{{count}} 件 · {{size}} がゴミ箱に移動されます',
+    andMore: '…ほか {{count}} 件',
+    warning: 'ファイルはソフト削除されます — 保持期間が終了するまでゴミ箱から復元できます。',
+    confirmCta: '{{count}} 件をゴミ箱へ移動',
+    purging: 'ゴミ箱へ移動中…',
+    toast: {
+      success: '{{count}} 件のファイルをゴミ箱に移動しました。',
+      failed: 'ファイルを削除できませんでした。',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: '古いファイルを自動で一括削除',
+    enableDescription: '管理者の一括削除を 1 日 1 回実行します。ファイルはまずゴミ箱に移動され、すぐには削除されません。',
+    ageLabel: '次より古いファイルを自動削除',
+    ageDescription: '最短 7 日、最長 10 年。手動の「古いファイル削除」ボタンと同じ期間ルールを使用します。',
+    days: '日',
+    includeNeverPrinted: '一度も印刷していないファイルも含める',
+    saveFailed: '自動削除の設定を保存できませんでした。',
+  },
 };

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

@@ -5065,4 +5065,86 @@ export default {
       deleteFailed: 'Não foi possível remover o arquivo da biblioteca.',
     },
   },
+  libraryTrash: {
+    title: 'Lixeira',
+    headerButton: 'Lixeira',
+    headerTooltip: 'Ver arquivos movidos para a lixeira',
+    backToFiles: 'Voltar ao gerenciador de arquivos',
+    subtitleAdmin: 'Arquivos excluídos ficam aqui por {{days}} dias e depois são removidos automaticamente. Esta visualização mostra a lixeira de todos os usuários.',
+    subtitleUser: 'Arquivos excluídos ficam aqui por {{days}} dias e depois são removidos automaticamente.',
+    loading: 'Carregando a lixeira…',
+    loadError: 'Não foi possível carregar a lixeira.',
+    empty: 'A lixeira está vazia.',
+    summary: '{{count}} arquivos · {{size}}',
+    emptyTrash: 'Esvaziar lixeira',
+    restore: 'Restaurar',
+    purgeNow: 'Excluir agora',
+    autoPurgeIn: 'Exclusão automática em {{when}}',
+    days: 'dias',
+    retentionLabel: 'Excluir automaticamente após',
+    selectAll: 'Selecionar todos',
+    selectOne: 'Selecionar {{filename}}',
+    selectionCount: '{{count}} selecionado(s)',
+    bulkRestore: 'Restaurar selecionados',
+    bulkPurge: 'Excluir selecionados',
+    col: {
+      filename: 'Arquivo',
+      folder: 'Pasta',
+      size: 'Tamanho',
+      deleted: 'Movido para a lixeira',
+      autoPurge: 'Exclusão auto.',
+      owner: 'Proprietário',
+      actions: 'Ações',
+    },
+    confirm: {
+      purgeTitle: 'Excluir permanentemente?',
+      purgeBody: '{{filename}} será excluído do disco e não poderá ser restaurado.',
+      emptyTitle: 'Esvaziar a lixeira?',
+      emptyBody: 'Todos os {{count}} arquivos serão excluídos do disco. Esta ação não pode ser desfeita.',
+      bulkPurgeTitle: 'Excluir permanentemente os arquivos selecionados?',
+      bulkPurgeBody: 'Os {{count}} arquivos selecionados serão excluídos do disco e não poderão ser restaurados.',
+      cta: 'Excluir permanentemente',
+    },
+    toast: {
+      restored: 'Arquivo restaurado.',
+      restoreFailed: 'Não foi possível restaurar o arquivo.',
+      purged: 'Arquivo excluído permanentemente.',
+      purgeFailed: 'Não foi possível excluir o arquivo.',
+      emptied: '{{count}} arquivo(s) excluído(s) da lixeira.',
+      emptyFailed: 'Não foi possível esvaziar a lixeira.',
+      retentionSaved: 'Exclusão automática definida para {{days}} dias.',
+      retentionFailed: 'Não foi possível salvar a configuração de retenção.',
+      bulkRestored: '{{count}} arquivo(s) restaurado(s).',
+      bulkPurged: '{{count}} arquivo(s) excluído(s).',
+    },
+  },
+  libraryPurge: {
+    title: 'Limpar arquivos antigos',
+    headerButton: 'Limpar antigos',
+    headerTooltip: 'Mover arquivos antigos em massa para a lixeira',
+    description: 'Arquivos mais antigos que o limite serão movidos para a lixeira. Pastas externas são ignoradas. Você pode restaurar da lixeira antes da exclusão automática.',
+    ageLabel: 'Mover arquivos mais antigos que',
+    days: 'dias',
+    includeNeverPrinted: 'Incluir arquivos que nunca foram impressos',
+    previewLoading: 'Verificando quantos arquivos correspondem…',
+    previewFailed: 'Não foi possível pré-visualizar a limpeza.',
+    previewSummary: '{{count}} arquivos · {{size}} seriam movidos para a lixeira',
+    andMore: '…e mais {{count}}',
+    warning: 'Os arquivos são excluídos de forma suave — você pode restaurá-los da lixeira até o fim do período de retenção.',
+    confirmCta: 'Mover {{count}} para a lixeira',
+    purging: 'Movendo para a lixeira…',
+    toast: {
+      success: '{{count}} arquivo(s) movido(s) para a lixeira.',
+      failed: 'Não foi possível limpar os arquivos.',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: 'Limpar arquivos antigos automaticamente',
+    enableDescription: 'Executa a limpeza administrativa uma vez por dia. Os arquivos vão primeiro para a lixeira — não são excluídos imediatamente.',
+    ageLabel: 'Limpar automaticamente arquivos mais antigos que',
+    ageDescription: 'Mínimo 7 dias, máximo 10 anos. Usa a mesma regra de idade do botão Limpar manual.',
+    days: 'dias',
+    includeNeverPrinted: 'Incluir arquivos que nunca foram impressos',
+    saveFailed: 'Não foi possível salvar as configurações de limpeza automática.',
+  },
 };

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

@@ -5129,4 +5129,86 @@ export default {
       deleteFailed: '无法从资料库中移除文件。',
     },
   },
+  libraryTrash: {
+    title: '回收站',
+    headerButton: '回收站',
+    headerTooltip: '查看已移至回收站的文件',
+    backToFiles: '返回文件管理器',
+    subtitleAdmin: '已删除的文件会在回收站保留 {{days}} 天,之后自动删除。此视图显示所有用户的回收站。',
+    subtitleUser: '已删除的文件会在回收站保留 {{days}} 天,之后自动删除。',
+    loading: '正在加载回收站…',
+    loadError: '无法加载回收站。',
+    empty: '回收站为空。',
+    summary: '{{count}} 个文件 · {{size}}',
+    emptyTrash: '清空回收站',
+    restore: '还原',
+    purgeNow: '立即删除',
+    autoPurgeIn: '将于 {{when}} 自动删除',
+    days: '天',
+    retentionLabel: '自动删除时间',
+    selectAll: '全选',
+    selectOne: '选择 {{filename}}',
+    selectionCount: '已选择 {{count}} 项',
+    bulkRestore: '还原所选',
+    bulkPurge: '删除所选',
+    col: {
+      filename: '文件',
+      folder: '文件夹',
+      size: '大小',
+      deleted: '移入回收站',
+      autoPurge: '自动删除',
+      owner: '所有者',
+      actions: '操作',
+    },
+    confirm: {
+      purgeTitle: '永久删除?',
+      purgeBody: '{{filename}} 将从磁盘中删除,无法恢复。',
+      emptyTitle: '清空回收站?',
+      emptyBody: '全部 {{count}} 个文件将从磁盘中删除。此操作无法撤消。',
+      bulkPurgeTitle: '永久删除所选文件?',
+      bulkPurgeBody: '所选的 {{count}} 个文件将从磁盘中删除,无法恢复。',
+      cta: '永久删除',
+    },
+    toast: {
+      restored: '文件已还原。',
+      restoreFailed: '无法还原文件。',
+      purged: '文件已永久删除。',
+      purgeFailed: '无法删除文件。',
+      emptied: '已从回收站删除 {{count}} 个文件。',
+      emptyFailed: '无法清空回收站。',
+      retentionSaved: '自动删除已设置为 {{days}} 天。',
+      retentionFailed: '无法保存保留设置。',
+      bulkRestored: '已还原 {{count}} 个文件。',
+      bulkPurged: '已删除 {{count}} 个文件。',
+    },
+  },
+  libraryPurge: {
+    title: '清理旧文件',
+    headerButton: '清理旧文件',
+    headerTooltip: '批量将旧文件移至回收站',
+    description: '早于所选阈值的文件将被移至回收站。外部文件夹会被跳过。在自动删除之前,您可以从回收站还原文件。',
+    ageLabel: '移动早于以下天数的文件',
+    days: '天',
+    includeNeverPrinted: '包括从未打印过的文件',
+    previewLoading: '正在检查匹配的文件数量…',
+    previewFailed: '无法预览清理结果。',
+    previewSummary: '{{count}} 个文件 · {{size}} 将被移至回收站',
+    andMore: '…还有 {{count}} 个',
+    warning: '文件将被软删除——在保留期结束前,您可以从回收站还原它们。',
+    confirmCta: '将 {{count}} 个移至回收站',
+    purging: '正在移至回收站…',
+    toast: {
+      success: '已将 {{count}} 个文件移至回收站。',
+      failed: '无法清理文件。',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: '自动清理旧文件',
+    enableDescription: '每天执行一次管理员清理。文件会先进入回收站——不会立即删除。',
+    ageLabel: '自动清理早于以下天数的文件',
+    ageDescription: '最少 7 天,最多 10 年。使用与手动「清理旧文件」按钮相同的时间规则。',
+    days: '天',
+    includeNeverPrinted: '包括从未打印过的文件',
+    saveFailed: '无法保存自动清理设置。',
+  },
 };

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

@@ -5129,4 +5129,86 @@ export default {
       deleteFailed: '無法從資料庫中移除檔案。',
     },
   },
+  libraryTrash: {
+    title: '資源回收筒',
+    headerButton: '資源回收筒',
+    headerTooltip: '檢視已移至資源回收筒的檔案',
+    backToFiles: '返回檔案管理員',
+    subtitleAdmin: '已刪除的檔案會在資源回收筒保留 {{days}} 天,之後自動刪除。此檢視顯示所有使用者的資源回收筒。',
+    subtitleUser: '已刪除的檔案會在資源回收筒保留 {{days}} 天,之後自動刪除。',
+    loading: '正在載入資源回收筒…',
+    loadError: '無法載入資源回收筒。',
+    empty: '資源回收筒是空的。',
+    summary: '{{count}} 個檔案 · {{size}}',
+    emptyTrash: '清空資源回收筒',
+    restore: '還原',
+    purgeNow: '立即刪除',
+    autoPurgeIn: '將於 {{when}} 自動刪除',
+    days: '天',
+    retentionLabel: '自動刪除時間',
+    selectAll: '全選',
+    selectOne: '選擇 {{filename}}',
+    selectionCount: '已選擇 {{count}} 項',
+    bulkRestore: '還原所選',
+    bulkPurge: '刪除所選',
+    col: {
+      filename: '檔案',
+      folder: '資料夾',
+      size: '大小',
+      deleted: '移入資源回收筒',
+      autoPurge: '自動刪除',
+      owner: '擁有者',
+      actions: '操作',
+    },
+    confirm: {
+      purgeTitle: '永久刪除?',
+      purgeBody: '{{filename}} 將從磁碟中刪除,無法還原。',
+      emptyTitle: '清空資源回收筒?',
+      emptyBody: '全部 {{count}} 個檔案將從磁碟中刪除。此操作無法復原。',
+      bulkPurgeTitle: '永久刪除所選檔案?',
+      bulkPurgeBody: '所選的 {{count}} 個檔案將從磁碟中刪除,無法還原。',
+      cta: '永久刪除',
+    },
+    toast: {
+      restored: '檔案已還原。',
+      restoreFailed: '無法還原檔案。',
+      purged: '檔案已永久刪除。',
+      purgeFailed: '無法刪除檔案。',
+      emptied: '已從資源回收筒刪除 {{count}} 個檔案。',
+      emptyFailed: '無法清空資源回收筒。',
+      retentionSaved: '自動刪除已設定為 {{days}} 天。',
+      retentionFailed: '無法儲存保留設定。',
+      bulkRestored: '已還原 {{count}} 個檔案。',
+      bulkPurged: '已刪除 {{count}} 個檔案。',
+    },
+  },
+  libraryPurge: {
+    title: '清理舊檔案',
+    headerButton: '清理舊檔案',
+    headerTooltip: '批次將舊檔案移至資源回收筒',
+    description: '早於所選時間的檔案將被移至資源回收筒。外部資料夾會被略過。在自動刪除之前,您可以從資源回收筒還原檔案。',
+    ageLabel: '移動早於以下天數的檔案',
+    days: '天',
+    includeNeverPrinted: '包括從未列印過的檔案',
+    previewLoading: '正在檢查符合的檔案數量…',
+    previewFailed: '無法預覽清理結果。',
+    previewSummary: '{{count}} 個檔案 · {{size}} 將被移至資源回收筒',
+    andMore: '…還有 {{count}} 個',
+    warning: '檔案將被軟刪除——在保留期結束前,您可以從資源回收筒還原它們。',
+    confirmCta: '將 {{count}} 個移至資源回收筒',
+    purging: '正在移至資源回收筒…',
+    toast: {
+      success: '已將 {{count}} 個檔案移至資源回收筒。',
+      failed: '無法清理檔案。',
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: '自動清理舊檔案',
+    enableDescription: '每天執行一次管理員清理。檔案會先進入資源回收筒——不會立即刪除。',
+    ageLabel: '自動清理早於以下天數的檔案',
+    ageDescription: '最少 7 天,最多 10 年。使用與手動「清理舊檔案」按鈕相同的時間規則。',
+    days: '天',
+    includeNeverPrinted: '包括從未列印過的檔案',
+    saveFailed: '無法儲存自動清理設定。',
+  },
 };

+ 49 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { Link, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
@@ -57,6 +57,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
+import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
@@ -903,6 +904,7 @@ export function FileManagerPage() {
   const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
+  const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
@@ -1002,6 +1004,21 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryFolders(),
   });
 
+  // Trash count for the header badge (#1008). Empty/error are silently treated
+  // as zero so a broken trash endpoint doesn't break the File Manager.
+  const { data: trashCount } = useQuery({
+    queryKey: ['library-trash-count'],
+    queryFn: async () => {
+      try {
+        const res = await api.listLibraryTrash(1, 0);
+        return res.total;
+      } catch {
+        return 0;
+      }
+    },
+    staleTime: 30_000,
+  });
+
   const { data: files, isLoading: filesLoading } = useQuery({
     queryKey: ['library-files', selectedFolderId],
     queryFn: () => api.getLibraryFiles(selectedFolderId, selectedFolderId === null),
@@ -1151,6 +1168,7 @@ export function FileManagerPage() {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-folders'] });
       queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
       setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id));
       setDeleteConfirm(null);
       showToast(t('fileManager.toast.fileDeleted'), 'success');
@@ -1167,6 +1185,7 @@ export function FileManagerPage() {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-folders'] });
       queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
       showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success');
       setSelectedFiles([]);
       setDeleteConfirm(null);
@@ -1425,6 +1444,31 @@ export function FileManagerPage() {
             <FolderPlus className="w-4 h-4 mr-2" />
             {t('fileManager.newFolder')}
           </Button>
+          {hasPermission('library:purge') && (
+            <Button
+              variant="secondary"
+              onClick={() => setShowPurgeModal(true)}
+              title={t('libraryPurge.headerTooltip')}
+            >
+              <Trash2 className="w-4 h-4 mr-2" />
+              {t('libraryPurge.headerButton')}
+            </Button>
+          )}
+          {(hasAnyPermission('library:delete_own', 'library:delete_all')) && (
+            <Link
+              to="/files/trash"
+              className="inline-flex items-center px-3 py-1.5 text-sm rounded bg-bambu-dark-secondary text-bambu-gray hover:text-white hover:bg-bambu-dark transition-colors"
+              title={t('libraryTrash.headerTooltip')}
+            >
+              <Trash2 className="w-4 h-4 mr-2" />
+              {t('libraryTrash.headerButton')}
+              {typeof trashCount === 'number' && trashCount > 0 && (
+                <span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-bambu-green/20 text-bambu-green">
+                  {trashCount}
+                </span>
+              )}
+            </Link>
+          )}
           <Button
             onClick={() => setShowUploadModal(true)}
             disabled={!hasPermission('library:upload')}
@@ -2152,6 +2196,10 @@ export function FileManagerPage() {
         />
       )}
 
+      {showPurgeModal && (
+        <PurgeOldFilesModal onClose={() => setShowPurgeModal(false)} />
+      )}
+
       {linkFolder && (
         <LinkFolderModal
           folder={linkFolder}

+ 398 - 0
frontend/src/pages/LibraryTrashPage.tsx

@@ -0,0 +1,398 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { ArrowLeft, RotateCcw, Save, Trash2, Loader2 } from 'lucide-react';
+
+import { api } from '../api/client';
+import { Button } from '../components/Button';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { formatFileSize } from '../utils/file';
+import { parseUTCDate } from '../utils/date';
+
+function formatRelativeDays(iso: string): string {
+  const target = parseUTCDate(iso);
+  if (!target) return '';
+  const days = Math.ceil((target.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+  return days <= 0 ? 'any moment' : days === 1 ? '1 day' : `${days} days`;
+}
+
+function formatDeletedAt(iso: string): string {
+  const date = parseUTCDate(iso);
+  return date ? date.toLocaleString() : iso;
+}
+
+type PendingAction =
+  | { type: 'delete'; id: number; filename: string }
+  | { type: 'empty' }
+  | { type: 'bulkDelete'; count: number }
+  | null;
+
+export function LibraryTrashPage() {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission, authEnabled } = useAuth();
+  const [pending, setPending] = useState<PendingAction>(null);
+  const [selected, setSelected] = useState<Set<number>>(new Set());
+
+  const isAdmin = !authEnabled || hasPermission('library:purge');
+
+  const trashQuery = useQuery({
+    queryKey: ['library-trash'],
+    queryFn: () => api.listLibraryTrash(200, 0),
+  });
+
+  const settingsQuery = useQuery({
+    queryKey: ['library-trash-settings'],
+    queryFn: () => api.getLibraryTrashSettings(),
+    enabled: isAdmin,
+  });
+
+  const [retentionDraft, setRetentionDraft] = useState<number | null>(null);
+  useEffect(() => {
+    if (settingsQuery.data && retentionDraft === null) {
+      setRetentionDraft(settingsQuery.data.retention_days);
+    }
+  }, [settingsQuery.data, retentionDraft]);
+
+  const updateRetentionMutation = useMutation({
+    mutationFn: (days: number) => {
+      // Preserve current auto-purge config — this control only touches retention.
+      const current = settingsQuery.data;
+      return api.updateLibraryTrashSettings({
+        retention_days: days,
+        auto_purge_enabled: current?.auto_purge_enabled ?? false,
+        auto_purge_days: current?.auto_purge_days ?? 90,
+        auto_purge_include_never_printed: current?.auto_purge_include_never_printed ?? true,
+      });
+    },
+    onSuccess: (res) => {
+      showToast(t('libraryTrash.toast.retentionSaved', { days: res.retention_days }), 'success');
+      queryClient.invalidateQueries({ queryKey: ['library-trash-settings'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash'] });
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.retentionFailed'), 'error'),
+  });
+
+  const restoreMutation = useMutation({
+    mutationFn: (id: number) => api.restoreLibraryTrash(id),
+    onSuccess: () => {
+      showToast(t('libraryTrash.toast.restored'), 'success');
+      queryClient.invalidateQueries({ queryKey: ['library-trash'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.restoreFailed'), 'error'),
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.hardDeleteLibraryTrash(id),
+    onSuccess: () => {
+      showToast(t('libraryTrash.toast.purged'), 'success');
+      queryClient.invalidateQueries({ queryKey: ['library-trash'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'),
+  });
+
+  const emptyMutation = useMutation({
+    mutationFn: () => api.emptyLibraryTrash(),
+    onSuccess: (result) => {
+      showToast(t('libraryTrash.toast.emptied', { count: result.deleted }), 'success');
+      queryClient.invalidateQueries({ queryKey: ['library-trash'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.emptyFailed'), 'error'),
+  });
+
+  // Bulk restore / delete run the existing per-item endpoints in parallel.
+  // The backend has no bulk endpoints (and given typical trash sizes of
+  // dozens of files, spinning up a Promise.all is fast enough that a new
+  // endpoint would be gratuitous).
+  const bulkRestoreMutation = useMutation({
+    mutationFn: (ids: number[]) => Promise.all(ids.map((id) => api.restoreLibraryTrash(id))),
+    onSuccess: (_, ids) => {
+      showToast(t('libraryTrash.toast.bulkRestored', { count: ids.length }), 'success');
+      setSelected(new Set());
+      queryClient.invalidateQueries({ queryKey: ['library-trash'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.restoreFailed'), 'error'),
+  });
+
+  const bulkDeleteMutation = useMutation({
+    mutationFn: (ids: number[]) => Promise.all(ids.map((id) => api.hardDeleteLibraryTrash(id))),
+    onSuccess: (_, ids) => {
+      showToast(t('libraryTrash.toast.bulkPurged', { count: ids.length }), 'success');
+      setSelected(new Set());
+      queryClient.invalidateQueries({ queryKey: ['library-trash'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'),
+  });
+
+  const items = trashQuery.data?.items ?? [];
+  const retentionDays = trashQuery.data?.retention_days ?? 30;
+  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 someSelected = selected.size > 0 && !allSelected;
+
+  const toggleOne = (id: number) => {
+    setSelected((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const toggleAll = () => {
+    setSelected((prev) => (prev.size === items.length ? new Set() : new Set(items.map((i) => i.id))));
+  };
+
+  const handleConfirm = () => {
+    if (!pending) return;
+    if (pending.type === 'delete') {
+      deleteMutation.mutate(pending.id);
+    } else if (pending.type === 'bulkDelete') {
+      bulkDeleteMutation.mutate(Array.from(selected));
+    } else {
+      emptyMutation.mutate();
+    }
+    setPending(null);
+  };
+
+  return (
+    <div className="p-6 max-w-screen-2xl mx-auto">
+      <div className="flex items-center gap-3 mb-4">
+        <Link
+          to="/files"
+          className="inline-flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
+        >
+          <ArrowLeft className="w-4 h-4" /> {t('libraryTrash.backToFiles')}
+        </Link>
+      </div>
+
+      <div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
+            {t('libraryTrash.title')}
+          </h1>
+          <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
+            {isAdmin
+              ? t('libraryTrash.subtitleAdmin', { days: retentionDays })
+              : t('libraryTrash.subtitleUser', { days: retentionDays })}
+          </p>
+        </div>
+        {items.length > 0 && (
+          <Button
+            variant="secondary"
+            onClick={() => setPending({ type: 'empty' })}
+            className="text-red-600 dark:text-red-400"
+          >
+            <Trash2 className="w-4 h-4 mr-1" />
+            {t('libraryTrash.emptyTrash')}
+          </Button>
+        )}
+      </div>
+
+      {isAdmin && settingsQuery.data && (
+        <div className="mb-4 border border-gray-200 dark:border-gray-700 rounded-lg p-3 flex items-center gap-3 bg-gray-50 dark:bg-gray-800/40">
+          <label htmlFor="retention-days" className="text-sm font-medium text-gray-700 dark:text-gray-300">
+            {t('libraryTrash.retentionLabel')}
+          </label>
+          <input
+            id="retention-days"
+            type="number"
+            min={1}
+            max={365}
+            value={retentionDraft ?? settingsQuery.data.retention_days}
+            onChange={(e) =>
+              setRetentionDraft(Math.max(1, Math.min(365, parseInt(e.target.value || '0', 10) || 0)))
+            }
+            className="w-20 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('libraryTrash.days')}</span>
+          <Button
+            variant="secondary"
+            onClick={() => retentionDraft != null && updateRetentionMutation.mutate(retentionDraft)}
+            disabled={
+              updateRetentionMutation.isPending ||
+              retentionDraft == null ||
+              retentionDraft === settingsQuery.data.retention_days
+            }
+            className="ml-auto"
+          >
+            <Save className="w-4 h-4 mr-1" />
+            {t('common.save')}
+          </Button>
+        </div>
+      )}
+
+      {trashQuery.isLoading ? (
+        <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
+          <Loader2 className="w-4 h-4 animate-spin" /> {t('libraryTrash.loading')}
+        </div>
+      ) : items.length === 0 ? (
+        <div className="border border-dashed border-gray-300 dark:border-gray-700 rounded-lg p-12 text-center">
+          <p className="text-gray-500 dark:text-gray-400">{t('libraryTrash.empty')}</p>
+        </div>
+      ) : (
+        <>
+          <div className="flex items-center justify-between mb-2">
+            <div className="text-xs text-gray-500 dark:text-gray-400">
+              {t('libraryTrash.summary', { count: items.length, size: formatFileSize(totalBytes) })}
+            </div>
+            {selected.size > 0 && (
+              <div className="flex items-center gap-2 text-sm">
+                <span className="text-gray-600 dark:text-gray-400">
+                  {t('libraryTrash.selectionCount', { count: selected.size })}
+                </span>
+                <Button
+                  variant="secondary"
+                  onClick={() => bulkRestoreMutation.mutate(Array.from(selected))}
+                  disabled={bulkRestoreMutation.isPending}
+                >
+                  <RotateCcw className="w-4 h-4 mr-1" />
+                  {t('libraryTrash.bulkRestore')}
+                </Button>
+                <Button
+                  variant="secondary"
+                  onClick={() => setPending({ type: 'bulkDelete', count: selected.size })}
+                  disabled={bulkDeleteMutation.isPending}
+                  className="text-red-600 dark:text-red-400"
+                >
+                  <Trash2 className="w-4 h-4 mr-1" />
+                  {t('libraryTrash.bulkPurge')}
+                </Button>
+              </div>
+            )}
+          </div>
+          <div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-x-auto">
+            <table className="w-full text-sm">
+              <thead className="bg-gray-50 dark:bg-gray-800 text-left text-gray-600 dark:text-gray-300">
+                <tr>
+                  <th className="px-3 py-2 w-10">
+                    <input
+                      type="checkbox"
+                      checked={allSelected}
+                      ref={(el) => {
+                        if (el) el.indeterminate = someSelected;
+                      }}
+                      onChange={toggleAll}
+                      aria-label={t('libraryTrash.selectAll')}
+                      className="rounded border-gray-300 cursor-pointer"
+                    />
+                  </th>
+                  <th className="px-3 py-2 font-medium">{t('libraryTrash.col.filename')}</th>
+                  <th className="px-3 py-2 font-medium">{t('libraryTrash.col.folder')}</th>
+                  <th className="px-3 py-2 font-medium text-right">{t('libraryTrash.col.size')}</th>
+                  <th className="px-3 py-2 font-medium whitespace-nowrap">{t('libraryTrash.col.deleted')}</th>
+                  <th className="px-3 py-2 font-medium whitespace-nowrap">{t('libraryTrash.col.autoPurge')}</th>
+                  {isAdmin && <th className="px-3 py-2 font-medium">{t('libraryTrash.col.owner')}</th>}
+                  <th className="px-3 py-2 font-medium text-right">{t('libraryTrash.col.actions')}</th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
+                {items.map((item) => (
+                  <tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
+                    <td className="px-3 py-2">
+                      <input
+                        type="checkbox"
+                        checked={selected.has(item.id)}
+                        onChange={() => toggleOne(item.id)}
+                        aria-label={t('libraryTrash.selectOne', { filename: item.filename })}
+                        className="rounded border-gray-300 cursor-pointer"
+                      />
+                    </td>
+                    <td
+                      className="px-3 py-2 text-gray-900 dark:text-gray-100 truncate max-w-md"
+                      title={item.filename}
+                    >
+                      {item.filename}
+                    </td>
+                    <td className="px-3 py-2 text-gray-600 dark:text-gray-400">{item.folder_name ?? '—'}</td>
+                    <td className="px-3 py-2 text-right text-gray-600 dark:text-gray-400 tabular-nums whitespace-nowrap">
+                      {formatFileSize(item.file_size)}
+                    </td>
+                    <td className="px-3 py-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">
+                      {formatDeletedAt(item.deleted_at)}
+                    </td>
+                    <td className="px-3 py-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">
+                      <span title={formatDeletedAt(item.auto_purge_at)}>
+                        {t('libraryTrash.autoPurgeIn', { when: formatRelativeDays(item.auto_purge_at) })}
+                      </span>
+                    </td>
+                    {isAdmin && (
+                      <td className="px-3 py-2 text-gray-600 dark:text-gray-400">
+                        {item.created_by_username ?? '—'}
+                      </td>
+                    )}
+                    <td className="px-3 py-2 text-right whitespace-nowrap">
+                      <button
+                        onClick={() => restoreMutation.mutate(item.id)}
+                        disabled={restoreMutation.isPending}
+                        className="inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
+                      >
+                        <RotateCcw className="w-3.5 h-3.5" />
+                        {t('libraryTrash.restore')}
+                      </button>
+                      <button
+                        onClick={() => setPending({ type: 'delete', id: item.id, filename: item.filename })}
+                        disabled={deleteMutation.isPending}
+                        className="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 ml-2"
+                      >
+                        <Trash2 className="w-3.5 h-3.5" />
+                        {t('libraryTrash.purgeNow')}
+                      </button>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+        </>
+      )}
+
+      {pending && (
+        <ConfirmModal
+          onCancel={() => setPending(null)}
+          onConfirm={handleConfirm}
+          title={
+            pending.type === 'delete'
+              ? t('libraryTrash.confirm.purgeTitle')
+              : pending.type === 'bulkDelete'
+                ? t('libraryTrash.confirm.bulkPurgeTitle')
+                : t('libraryTrash.confirm.emptyTitle')
+          }
+          message={
+            pending.type === 'delete'
+              ? t('libraryTrash.confirm.purgeBody', { filename: pending.filename })
+              : pending.type === 'bulkDelete'
+                ? t('libraryTrash.confirm.bulkPurgeBody', { count: pending.count })
+                : t('libraryTrash.confirm.emptyBody', { count: items.length })
+          }
+          confirmText={t('libraryTrash.confirm.cta')}
+          variant="danger"
+        />
+      )}
+
+      {/* Small escape hatch in case the user navigated here without auth */}
+      {trashQuery.isError && (
+        <div className="mt-4 text-sm text-red-600 dark:text-red-400">
+          {(trashQuery.error as Error | null)?.message ?? t('libraryTrash.loadError')}
+          <Button variant="secondary" onClick={() => navigate('/files')} className="ml-3">
+            {t('libraryTrash.backToFiles')}
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+}

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

@@ -454,6 +454,45 @@ export function SettingsPage() {
     queryFn: api.getVersion,
   });
 
+  // Library trash settings (#1008). Separate endpoint from the generic
+  // /settings — persists retention window + auto-purge config. Admin-only.
+  const canPurge = !authEnabled || hasPermission('library:purge');
+  const { data: trashSettings } = useQuery({
+    queryKey: ['library-trash-settings'],
+    queryFn: () => api.getLibraryTrashSettings(),
+    enabled: canPurge,
+  });
+
+  const updateTrashSettingsMutation = useMutation({
+    mutationFn: (body: {
+      retention_days: number;
+      auto_purge_enabled: boolean;
+      auto_purge_days: number;
+      auto_purge_include_never_printed: boolean;
+    }) => api.updateLibraryTrashSettings(body),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['library-trash-settings'] });
+      showToast(t('settings.toast.settingsSaved'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message || t('libraryAutoPurge.saveFailed'), 'error'),
+  });
+
+  const saveTrashSettings = (patch: Partial<{
+    retention_days: number;
+    auto_purge_enabled: boolean;
+    auto_purge_days: number;
+    auto_purge_include_never_printed: boolean;
+  }>) => {
+    if (!trashSettings) return;
+    updateTrashSettingsMutation.mutate({
+      retention_days: trashSettings.retention_days,
+      auto_purge_enabled: trashSettings.auto_purge_enabled,
+      auto_purge_days: trashSettings.auto_purge_days,
+      auto_purge_include_never_printed: trashSettings.auto_purge_include_never_printed,
+      ...patch,
+    });
+  };
+
   const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
@@ -1958,6 +1997,65 @@ export function SettingsPage() {
                   {t('settings.lowDiskSpaceDescription')}
                 </p>
               </div>
+
+              {/* Auto-purge (#1008). Admin-only — users without library:purge
+                  don't see this section since they can't trigger a bulk purge
+                  even manually. */}
+              {canPurge && trashSettings && (
+                <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('libraryAutoPurge.enableLabel')}</p>
+                      <p className="text-sm text-bambu-gray">{t('libraryAutoPurge.enableDescription')}</p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={trashSettings.auto_purge_enabled}
+                        onChange={(e) => saveTrashSettings({ auto_purge_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('libraryAutoPurge.ageLabel')}
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min={7}
+                        max={3650}
+                        disabled={!trashSettings.auto_purge_enabled}
+                        value={trashSettings.auto_purge_days}
+                        onChange={(e) =>
+                          saveTrashSettings({
+                            auto_purge_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('libraryAutoPurge.days')}</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      {t('libraryAutoPurge.ageDescription')}
+                    </p>
+                  </div>
+
+                  <label className="flex items-center gap-2 text-sm text-white cursor-pointer">
+                    <input
+                      type="checkbox"
+                      disabled={!trashSettings.auto_purge_enabled}
+                      checked={trashSettings.auto_purge_include_never_printed}
+                      onChange={(e) => saveTrashSettings({ auto_purge_include_never_printed: e.target.checked })}
+                      className="rounded border-gray-300 disabled:opacity-50"
+                    />
+                    {t('libraryAutoPurge.includeNeverPrinted')}
+                  </label>
+                </div>
+              )}
             </CardContent>
           </Card>
         </div>

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


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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Cl5t9soF.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DequwckK.css">
+    <script type="module" crossorigin src="/assets/index-D6c6Ck5Z.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-ByQZwYKW.css">
   </head>
   <body>
     <div id="root"></div>

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