media-rip/backend/app/services/purge.py
xpltd c3278fcac2 Privacy Mode: consolidated purge + auto-cleanup
Privacy Mode feature:
- Toggle in Admin > Settings enables automatic purge of download
  history, session logs, and files after configurable retention period
- Default retention: 24 hours when privacy mode is on
- Configurable 1-8760 hours via number input
- When enabled, starts purge scheduler (every 30 min) if not running
- When disabled, data persists indefinitely

Admin panel consolidation:
- Removed separate 'Purge' tab — manual purge moved to Settings
- Settings tab order: Privacy Mode > Manual Purge > Welcome Message >
  Output Formats > Change Password
- Toggle switch UI with accent color and smooth animation
- Retention input with left accent border and unit label

Backend:
- PurgeConfig: added privacy_mode (bool) and privacy_retention_hours
- Purge service: uses privacy_retention_hours when privacy mode active
- PUT /admin/settings: accepts privacy_mode + privacy_retention_hours
- GET /config/public: exposes privacy settings to frontend
- Runtime overrides passed to purge service via config._runtime_overrides
2026-03-19 05:55:08 -05:00

108 lines
3.3 KiB
Python

"""Purge service — clean up expired downloads and database rows.
Respects active job protection: never deletes files for jobs with
status in (queued, extracting, downloading).
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone, timedelta
from pathlib import Path
import aiosqlite
from app.core.config import AppConfig
logger = logging.getLogger("mediarip.purge")
async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
"""Execute a purge cycle.
When privacy_mode is active, uses privacy_retention_hours.
Otherwise uses max_age_hours.
Deletes completed/failed/expired jobs older than the configured
retention period and their associated files from disk.
Returns a summary dict with counts.
"""
overrides = getattr(config, "_runtime_overrides", {})
privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode)
if privacy_on:
retention = overrides.get(
"privacy_retention_hours", config.purge.privacy_retention_hours
)
else:
retention = config.purge.max_age_hours
output_dir = Path(config.downloads.output_dir)
cutoff = (datetime.now(timezone.utc) - timedelta(hours=retention)).isoformat()
logger.info("Purge starting: retention=%dh (privacy=%s), cutoff=%s", retention, privacy_on, cutoff)
# Find purgeable jobs — terminal status AND older than cutoff
cursor = await db.execute(
"""
SELECT id, filename FROM jobs
WHERE status IN ('completed', 'failed', 'expired')
AND completed_at IS NOT NULL
AND completed_at < ?
""",
(cutoff,),
)
rows = await cursor.fetchall()
files_deleted = 0
files_missing = 0
rows_deleted = 0
for row in rows:
job_id = row["id"]
filename = row["filename"]
# Delete file from disk if it exists
if filename:
file_path = output_dir / Path(filename).name
if file_path.is_file():
try:
file_path.unlink()
files_deleted += 1
logger.debug("Purge: deleted file %s (job %s)", file_path, job_id)
except OSError as e:
logger.warning("Purge: failed to delete %s: %s", file_path, e)
else:
files_missing += 1
logger.debug("Purge: file already gone %s (job %s)", file_path, job_id)
# Delete DB row
await db.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
rows_deleted += 1
await db.commit()
# Count skipped active jobs for observability
active_cursor = await db.execute(
"SELECT COUNT(*) FROM jobs WHERE status IN ('queued', 'extracting', 'downloading')"
)
active_row = await active_cursor.fetchone()
active_skipped = active_row[0] if active_row else 0
result = {
"rows_deleted": rows_deleted,
"files_deleted": files_deleted,
"files_missing": files_missing,
"active_skipped": active_skipped,
}
logger.info(
"Purge complete: %d rows deleted, %d files deleted, %d files already gone, %d active skipped",
rows_deleted,
files_deleted,
files_missing,
active_skipped,
)
return result