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

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

@@ -9,6 +9,7 @@ import re
 import shutil
 import shutil
 import uuid
 import uuid
 import zipfile
 import zipfile
+from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 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.
     # Source-URL-based dedupe: return the existing row untouched.
     if source_url:
     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()
         existing_row = existing.scalar_one_or_none()
         if existing_row is not None:
         if existing_row is not None:
             return existing_row, True
             return existing_row, True
@@ -374,7 +375,7 @@ async def list_folders(
     # Get file counts per folder
     # Get file counts per folder
     file_counts_result = await db.execute(
     file_counts_result = await db.execute(
         select(LibraryFile.folder_id, func.count(LibraryFile.id))
         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)
         .group_by(LibraryFile.folder_id)
     )
     )
     file_counts = dict(file_counts_result.all())
     file_counts = dict(file_counts_result.all())
@@ -430,7 +431,10 @@ async def get_folders_by_project(
     for folder, project_name in rows:
     for folder, project_name in rows:
         # Get file count
         # Get file count
         file_count_result = await db.execute(
         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
         file_count = file_count_result.scalar() or 0
 
 
@@ -475,7 +479,10 @@ async def get_folders_by_archive(
     for folder, archive_name in rows:
     for folder, archive_name in rows:
         # Get file count
         # Get file count
         file_count_result = await db.execute(
         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
         file_count = file_count_result.scalar() or 0
 
 
@@ -582,7 +589,12 @@ async def get_folder(
     folder, project_name, archive_name = row
     folder, project_name, archive_name = row
 
 
     # Get file count
     # 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
     file_count = file_count_result.scalar() or 0
 
 
     return FolderResponse(
     return FolderResponse(
@@ -668,7 +680,12 @@ async def update_folder(
     await db.refresh(folder)
     await db.refresh(folder)
 
 
     # Get file count and names
     # 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
     file_count = file_count_result.scalar() or 0
 
 
     # Get project and archive names
     # Get project and archive names
@@ -917,7 +934,7 @@ async def scan_external_folder(
 
 
     # Get existing DB files across root and all subfolders
     # Get existing DB files across root and all subfolders
     existing_result = await db.execute(
     existing_result = await db.execute(
-        select(LibraryFile).where(
+        LibraryFile.active().where(
             LibraryFile.folder_id.in_(all_folder_ids),
             LibraryFile.folder_id.in_(all_folder_ids),
             LibraryFile.is_external.is_(True),
             LibraryFile.is_external.is_(True),
         )
         )
@@ -1131,7 +1148,12 @@ async def scan_external_folder(
         if rel_path in seen_rel_dirs:
         if rel_path in seen_rel_dirs:
             continue  # Directory still exists on disk
             continue  # Directory still exists on disk
         # Check if subfolder has any remaining files
         # 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:
         if (file_count_result.scalar() or 0) == 0:
             # Check if it has any remaining child folders
             # Check if it has any remaining child folders
             child_count_result = await db.execute(
             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.
         include_root: If True and folder_id is None, returns files at root level.
                      If False and folder_id is None, returns all files.
                      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:
     if folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
         query = query.where(LibraryFile.folder_id == folder_id)
@@ -1191,7 +1213,7 @@ async def list_files(
         if hashes:
         if hashes:
             dup_result = await db.execute(
             dup_result = await db.execute(
                 select(LibraryFile.file_hash, func.count(LibraryFile.id))
                 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)
                 .group_by(LibraryFile.file_hash)
             )
             )
             hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self
             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)
         file_hash = calculate_file_hash(file_path)
 
 
         # Check for duplicates
         # 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()
         duplicate_of = dup_result.scalar()
 
 
         # Extract metadata and thumbnail
         # Extract metadata and thumbnail
@@ -1659,7 +1683,7 @@ async def batch_generate_stl_thumbnails(
     results: list[BatchThumbnailResult] = []
     results: list[BatchThumbnailResult] = []
 
 
     # Build query based on request
     # 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:
     if request.file_ids:
         # Specific files
         # Specific files
@@ -1782,7 +1806,7 @@ async def add_files_to_queue(
     errors: list[AddToQueueError] = []
     errors: list[AddToQueueError] = []
 
 
     # Get all requested files
     # 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()}
     files = {f.id: f for f in result.scalars().all()}
 
 
     # Get max position for queue ordering
     # Get max position for queue ordering
@@ -1862,7 +1886,7 @@ async def get_library_file_plates(
     import defusedxml.ElementTree as ET
     import defusedxml.ElementTree as ET
 
 
     # Get the library file
     # 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()
     lib_file = result.scalar_one_or_none()
 
 
     if not lib_file:
     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."""
     """Get the thumbnail image for a specific plate from a library file."""
     from starlette.responses import Response
     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()
     lib_file = result.scalar_one_or_none()
 
 
     if not lib_file:
     if not lib_file:
@@ -2161,7 +2185,7 @@ async def get_library_file_filament_requirements(
     import defusedxml.ElementTree as ET
     import defusedxml.ElementTree as ET
 
 
     # Get the library file
     # 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()
     lib_file = result.scalar_one_or_none()
 
 
     if not lib_file:
     if not lib_file:
@@ -2299,7 +2323,7 @@ async def print_library_file(
         body = FilePrintRequest()
         body = FilePrintRequest()
 
 
     # Get the library file
     # 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()
     lib_file = result.scalar_one_or_none()
 
 
     if not lib_file:
     if not lib_file:
@@ -2378,7 +2402,7 @@ async def get_file(
 ):
 ):
     """Get a file by ID with full details."""
     """Get a file by ID with full details."""
     result = await db.execute(
     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()
     file = result.scalar_one_or_none()
 
 
@@ -2404,7 +2428,11 @@ async def get_file(
         dup_result = await db.execute(
         dup_result = await db.execute(
             select(LibraryFile, LibraryFolder.name)
             select(LibraryFile, LibraryFolder.name)
             .outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
             .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():
         for dup_file, dup_folder_name in dup_result.all():
             duplicates.append(
             duplicates.append(
@@ -2473,7 +2501,7 @@ async def update_file(
     """Update a file's metadata."""
     """Update a file's metadata."""
     user, can_modify_all = auth_result
     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()
     file = result.scalar_one_or_none()
 
 
     if not file:
     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
     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()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
@@ -2548,23 +2583,22 @@ async def delete_file(
         if file.created_by_id != user.id:
         if file.created_by_id != user.id:
             raise HTTPException(status_code=403, detail="You can only delete your own files")
             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)
         abs_thumb_path = to_absolute_path(file.thumbnail_path)
         if abs_thumb_path and abs_thumb_path.exists():
         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()
     await db.commit()
-
-    return {"status": "success", "message": "File deleted"}
+    return {"status": "success", "message": "File moved to trash", "trashed": True}
 
 
 
 
 # ============ File Content Endpoints ============
 # ============ File Content Endpoints ============
@@ -2577,7 +2611,7 @@ async def download_file(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
 ):
     """Download a file."""
     """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()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
@@ -2607,7 +2641,7 @@ async def create_library_slicer_token(
     """
     """
     from backend.app.core.auth import create_slicer_download_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()
     file = result.scalar_one_or_none()
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         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):
     if not await verify_slicer_download_token(token, "library", file_id):
         raise HTTPException(status_code=403, detail="Invalid or expired download token")
         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()
     file = result.scalar_one_or_none()
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
@@ -2657,7 +2691,7 @@ async def get_thumbnail(
     _: None = RequireCameraStreamTokenIfAuthEnabled,
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
 ):
     """Get a file's thumbnail."""
     """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()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
@@ -2688,7 +2722,7 @@ async def get_gcode(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
 ):
     """Get gcode for a file (for preview)."""
     """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()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
@@ -2751,7 +2785,7 @@ async def move_files(
     moved = 0
     moved = 0
     skipped = 0
     skipped = 0
     for file_id in data.file_ids:
     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()
         file = result.scalar_one_or_none()
         if file:
         if file:
             # Ownership check
             # Ownership check
@@ -2788,28 +2822,30 @@ async def bulk_delete(
     deleted_folders = 0
     deleted_folders = 0
     skipped_files = 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:
     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()
         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()
                     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)
             await db.delete(file)
-            deleted_files += 1
+        else:
+            file.deleted_at = now
+        deleted_files += 1
 
 
     # Delete folders (cascade will handle contents)
     # Delete folders (cascade will handle contents)
     # Note: Folders don't have ownership tracking currently, require *_all permission
     # Note: Folders don't have ownership tracking currently, require *_all permission
@@ -2823,7 +2859,10 @@ async def bulk_delete(
         if folder:
         if folder:
             # Count files that will be deleted
             # Count files that will be deleted
             file_count_result = await db.execute(
             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
             deleted_files += file_count_result.scalar() or 0
             await db.delete(folder)
             await db.delete(folder)
@@ -2843,8 +2882,11 @@ async def get_library_stats(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
 ):
     """Get library statistics."""
     """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
-    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_files = total_files_result.scalar() or 0
 
 
     # Total folders
     # Total folders
@@ -2852,17 +2894,17 @@ async def get_library_stats(
     total_folders = total_folders_result.scalar() or 0
     total_folders = total_folders_result.scalar() or 0
 
 
     # Total size
     # 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
     total_size = total_size_result.scalar() or 0
 
 
     # Files by type
     # Files by type
     type_result = await db.execute(
     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())
     files_by_type = dict(type_result.all())
 
 
     # Total prints
     # 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
     total_prints = total_prints_result.scalar() or 0
 
 
     # Disk space info
     # 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)
     model_prefix = _canonical_url(model_id)
     existing_q = await db.execute(
     existing_q = await db.execute(
         select(LibraryFile.id).where(
         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()]
     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.
     # Dedupe check upfront so we don't burn bandwidth re-downloading.
     if source_url:
     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()
         existing_row = existing_q.scalar_one_or_none()
         if existing_row is not None:
         if existing_row is not None:
             await service.close()
             await service.close()
@@ -375,7 +376,7 @@ async def recent_imports(
     _ = current_user  # permission gate only
     _ = current_user  # permission gate only
     capped = max(1, min(50, int(limit)))
     capped = max(1, min(50, int(limit)))
     result = await db.execute(
     result = await db.execute(
-        select(LibraryFile)
+        LibraryFile.active()
         .where(LibraryFile.source_type == _SOURCE_TYPE)
         .where(LibraryFile.source_type == _SOURCE_TYPE)
         .order_by(LibraryFile.created_at.desc())
         .order_by(LibraryFile.created_at.desc())
         .limit(capped)
         .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
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
     library_file = None
     if data.library_file_id:
     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()
         library_file = result.scalar_one_or_none()
         if not library_file:
         if not library_file:
             raise HTTPException(400, "Library file not found")
             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:
     for folder in linked_folders:
         # Get files in this folder
         # Get files in this folder
         files_result = await db.execute(
         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()
         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
     queue_items_count = queue_result.scalar() or 0
 
 
     # Count library files
     # 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
     library_files_count = library_result.scalar() or 0
 
 
     return {
     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)",
         "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
     # Seed default settings keys that must exist on fresh install
     default_settings = [
     default_settings = [
         ("advanced_auth_enabled", "false"),
         ("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_UPDATE_ALL = "library:update_all"
     LIBRARY_DELETE_OWN = "library:delete_own"
     LIBRARY_DELETE_OWN = "library:delete_own"
     LIBRARY_DELETE_ALL = "library:delete_all"
     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
     PROJECTS_READ = "projects:read"
     PROJECTS_READ = "projects:read"
@@ -202,6 +206,7 @@ PERMISSION_CATEGORIES = {
         Permission.LIBRARY_UPDATE_ALL,
         Permission.LIBRARY_UPDATE_ALL,
         Permission.LIBRARY_DELETE_OWN,
         Permission.LIBRARY_DELETE_OWN,
         Permission.LIBRARY_DELETE_ALL,
         Permission.LIBRARY_DELETE_ALL,
+        Permission.LIBRARY_PURGE,
     ],
     ],
     "Projects": [
     "Projects": [
         Permission.PROJECTS_READ,
         Permission.PROJECTS_READ,

+ 7 - 0
backend/app/main.py

@@ -30,6 +30,7 @@ from backend.app.api.routes import (
     inventory,
     inventory,
     kprofiles,
     kprofiles,
     library,
     library,
+    library_trash,
     local_backup,
     local_backup,
     local_presets,
     local_presets,
     maintenance,
     maintenance,
@@ -77,6 +78,7 @@ from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.homeassistant import homeassistant_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.local_backup import local_backup_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 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 local_backup_service.start_scheduler()
     await obico_detection_service.start()
     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
     start_ams_history_recording()
     start_ams_history_recording()
 
 
@@ -4233,6 +4238,7 @@ async def lifespan(app: FastAPI):
     notification_service.stop_digest_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
+    library_trash_service.stop_scheduler()
     obico_detection_service.stop()
     obico_detection_service.stop()
     stop_ams_history_recording()
     stop_ams_history_recording()
     stop_runtime_tracking()
     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(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
+app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)

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

@@ -2,7 +2,7 @@
 
 
 from datetime import datetime
 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 sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -93,6 +93,11 @@ class LibraryFile(Base):
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     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
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     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())
     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()
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | 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.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # 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
         from backend.app.main import register_expected_print
 
 
         async with async_session() as db:
         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:
             if not lib_file:
                 raise RuntimeError("File not found")
                 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:
             if archive:
                 file_path = settings.base_dir / archive.file_path
                 file_path = settings.base_dir / archive.file_path
         elif item.library_file_id:
         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()
             library_file = result.scalar_one_or_none()
             if library_file:
             if library_file:
                 lib_path = Path(library_file.file_path)
                 lib_path = Path(library_file.file_path)
@@ -1565,7 +1565,7 @@ class PrintScheduler:
             if archive:
             if archive:
                 return archive.filename.replace(".gcode.3mf", "").replace(".3mf", "")
                 return archive.filename.replace(".gcode.3mf", "").replace(".3mf", "")
         if item.library_file_id:
         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()
             library_file = result.scalar_one_or_none()
             if library_file:
             if library_file:
                 return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
                 return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
@@ -1631,7 +1631,7 @@ class PrintScheduler:
 
 
         elif item.library_file_id:
         elif item.library_file_id:
             # Print from library file (file manager)
             # 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()
             library_file = result.scalar_one_or_none()
             if not library_file:
             if not library_file:
                 item.status = "failed"
                 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)
     # 1. Try library files matching the name (match base name at file boundary)
     try:
     try:
         lib_result = await db.execute(
         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(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .order_by(LibraryFile.created_at.desc())
             .order_by(LibraryFile.created_at.desc())
@@ -679,7 +679,7 @@ async def _find_3mf_by_filename(
     # 1. Try library files matching the name
     # 1. Try library files matching the name
     try:
     try:
         lib_result = await db.execute(
         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(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .order_by(LibraryFile.created_at.desc())
             .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 { ProjectsPage } from './pages/ProjectsPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { FileManagerPage } from './pages/FileManagerPage';
 import { FileManagerPage } from './pages/FileManagerPage';
+import { LibraryTrashPage } from './pages/LibraryTrashPage';
 import { CameraPage } from './pages/CameraPage';
 import { CameraPage } from './pages/CameraPage';
 import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
@@ -193,6 +194,7 @@ function App() {
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="files" element={<FileManagerPage />} />
+                  <Route path="files/trash" element={<LibraryTrashPage />} />
                   <Route path="makerworld" element={<MakerworldPage />} />
                   <Route path="makerworld" element={<MakerworldPage />} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></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'
   | 'queue:reorder'
   | 'library:read' | 'library:upload'
   | 'library:read' | 'library:upload'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
+  | 'library:purge'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete' | 'inventory:view_assignments'
   | 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete' | 'inventory:view_assignments'
@@ -4634,7 +4635,32 @@ export const api = {
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
   deleteLibraryFile: (id: number) =>
   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`,
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
   createLibrarySlicerToken: (fileId: number) =>
   createLibrarySlicerToken: (fileId: number) =>
     request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
     request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
@@ -5095,6 +5121,41 @@ export interface LibraryFileUpdate {
   notes?: string | null;
   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 {
 export interface LibraryFileUploadResponse {
   id: number;
   id: number;
   filename: string;
   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.',
       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.',
       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.',
       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.',
       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: 'ライブラリからファイルを削除できませんでした。',
       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.',
       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: '无法从资料库中移除文件。',
       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: '無法從資料庫中移除檔案。',
       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 { 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
@@ -57,6 +57,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FileUploadModal } from '../components/FileUploadModal';
+import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -903,6 +904,7 @@ export function FileManagerPage() {
   const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
+  const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
@@ -1002,6 +1004,21 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryFolders(),
     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({
   const { data: files, isLoading: filesLoading } = useQuery({
     queryKey: ['library-files', selectedFolderId],
     queryKey: ['library-files', selectedFolderId],
     queryFn: () => api.getLibraryFiles(selectedFolderId, selectedFolderId === null),
     queryFn: () => api.getLibraryFiles(selectedFolderId, selectedFolderId === null),
@@ -1151,6 +1168,7 @@ export function FileManagerPage() {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-folders'] });
       queryClient.invalidateQueries({ queryKey: ['library-folders'] });
       queryClient.invalidateQueries({ queryKey: ['library-stats'] });
       queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
       setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id));
       setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id));
       setDeleteConfirm(null);
       setDeleteConfirm(null);
       showToast(t('fileManager.toast.fileDeleted'), 'success');
       showToast(t('fileManager.toast.fileDeleted'), 'success');
@@ -1167,6 +1185,7 @@ export function FileManagerPage() {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-folders'] });
       queryClient.invalidateQueries({ queryKey: ['library-folders'] });
       queryClient.invalidateQueries({ queryKey: ['library-stats'] });
       queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
       showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success');
       showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success');
       setSelectedFiles([]);
       setSelectedFiles([]);
       setDeleteConfirm(null);
       setDeleteConfirm(null);
@@ -1425,6 +1444,31 @@ export function FileManagerPage() {
             <FolderPlus className="w-4 h-4 mr-2" />
             <FolderPlus className="w-4 h-4 mr-2" />
             {t('fileManager.newFolder')}
             {t('fileManager.newFolder')}
           </Button>
           </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
           <Button
             onClick={() => setShowUploadModal(true)}
             onClick={() => setShowUploadModal(true)}
             disabled={!hasPermission('library:upload')}
             disabled={!hasPermission('library:upload')}
@@ -2152,6 +2196,10 @@ export function FileManagerPage() {
         />
         />
       )}
       )}
 
 
+      {showPurgeModal && (
+        <PurgeOldFilesModal onClose={() => setShowPurgeModal(false)} />
+      )}
+
       {linkFolder && (
       {linkFolder && (
         <LinkFolderModal
         <LinkFolderModal
           folder={linkFolder}
           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,
     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({
   const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
     queryKey: ['updateCheck'],
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
     queryFn: api.checkForUpdates,
@@ -1958,6 +1997,65 @@ export function SettingsPage() {
                   {t('settings.lowDiskSpaceDescription')}
                   {t('settings.lowDiskSpaceDescription')}
                 </p>
                 </p>
               </div>
               </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>
             </CardContent>
           </Card>
           </Card>
         </div>
         </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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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