From c3278fcac2655540fb0f6ac250f64766fa344ee2 Mon Sep 17 00:00:00 2001 From: xpltd Date: Thu, 19 Mar 2026 05:55:08 -0500 Subject: [PATCH] Privacy Mode: consolidated purge + auto-cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/core/config.py | 2 + backend/app/routers/admin.py | 43 ++++++ backend/app/routers/system.py | 4 + backend/app/services/purge.py | 24 +++- frontend/src/api/types.ts | 2 + frontend/src/components/AdminPanel.vue | 165 ++++++++++++++++++++--- frontend/src/stores/admin.ts | 2 +- frontend/src/tests/stores/config.test.ts | 2 + 8 files changed, 217 insertions(+), 27 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ae5a23e..f1061f0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -67,6 +67,8 @@ class PurgeConfig(BaseModel): enabled: bool = False max_age_hours: int = 168 # 7 days cron: str = "0 3 * * *" # 3 AM daily + privacy_mode: bool = False + privacy_retention_hours: int = 24 # default when privacy mode enabled class UIConfig(BaseModel): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 80a47be..bc31779 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -155,6 +155,9 @@ async def manual_purge( config = request.app.state.config db = request.app.state.db + # Attach runtime overrides so purge service can read them + overrides = getattr(request.app.state, "settings_overrides", {}) + config._runtime_overrides = overrides result = await run_purge(db, config) return result @@ -207,6 +210,46 @@ async def update_settings( updated.append("default_audio_format") logger.info("Admin updated default_audio_format to: %s", fmt) + if "privacy_mode" in body: + val = body["privacy_mode"] + if isinstance(val, bool): + request.app.state.settings_overrides["privacy_mode"] = val + # When enabling privacy mode, also enable the purge scheduler + config = request.app.state.config + if val and not config.purge.enabled: + config.purge.enabled = True + # Start the scheduler if APScheduler is available + try: + from apscheduler.schedulers.asyncio import AsyncIOScheduler + from apscheduler.triggers.cron import CronTrigger + from app.services.purge import run_purge + + if not hasattr(request.app.state, "scheduler"): + scheduler = AsyncIOScheduler() + scheduler.add_job( + run_purge, + CronTrigger(minute="*/30"), # every 30 min for privacy + args=[request.app.state.db, config], + id="purge_job", + name="Privacy purge", + replace_existing=True, + ) + scheduler.start() + request.app.state.scheduler = scheduler + logger.info("Privacy mode: started purge scheduler (every 30 min)") + except Exception as e: + logger.warning("Could not start purge scheduler: %s", e) + updated.append("privacy_mode") + logger.info("Admin updated privacy_mode to: %s", val) + + if "privacy_retention_hours" in body: + val = body["privacy_retention_hours"] + if isinstance(val, (int, float)) and 1 <= val <= 8760: # 1 hour to 1 year + request.app.state.settings_overrides["privacy_retention_hours"] = int(val) + updated.append("privacy_retention_hours") + logger.info("Admin updated privacy_retention_hours to: %d", int(val)) + logger.info("Admin updated default_audio_format to: %s", fmt) + return {"updated": updated, "status": "ok"} diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index cf6a5b9..6189097 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -34,4 +34,8 @@ async def public_config(request: Request) -> dict: "max_concurrent_downloads": config.downloads.max_concurrent, "default_video_format": overrides.get("default_video_format", "auto"), "default_audio_format": overrides.get("default_audio_format", "auto"), + "privacy_mode": overrides.get("privacy_mode", config.purge.privacy_mode), + "privacy_retention_hours": overrides.get( + "privacy_retention_hours", config.purge.privacy_retention_hours + ), } diff --git a/backend/app/services/purge.py b/backend/app/services/purge.py index f458af5..20c8a68 100644 --- a/backend/app/services/purge.py +++ b/backend/app/services/purge.py @@ -20,16 +20,28 @@ logger = logging.getLogger("mediarip.purge") async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict: """Execute a purge cycle. - Deletes completed/failed/expired jobs older than ``config.purge.max_age_hours`` - and their associated files from disk. + 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. """ - max_age_hours = config.purge.max_age_hours - output_dir = Path(config.downloads.output_dir) - cutoff = (datetime.now(timezone.utc) - timedelta(hours=max_age_hours)).isoformat() + overrides = getattr(config, "_runtime_overrides", {}) + privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode) - logger.info("Purge starting: max_age=%dh, cutoff=%s", max_age_hours, cutoff) + 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( diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f681f77..7901b72 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -75,6 +75,8 @@ export interface PublicConfig { max_concurrent_downloads: number default_video_format: string default_audio_format: string + privacy_mode: boolean + privacy_retention_hours: number } export interface HealthStatus { diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 8660ac7..c3bd06a 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -9,7 +9,7 @@ import AdminLogin from './AdminLogin.vue' const store = useAdminStore() const configStore = useConfigStore() const router = useRouter() -const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions') +const activeTab = ref<'sessions' | 'storage' | 'settings'>('sessions') // Session expansion state const expandedSessions = ref>(new Set()) @@ -21,6 +21,8 @@ const welcomeMessage = ref('') const defaultVideoFormat = ref('auto') const defaultAudioFormat = ref('auto') const settingsSaved = ref(false) +const privacyMode = ref(false) +const privacyRetentionHours = ref(24) // Change password state const currentPassword = ref('') @@ -54,6 +56,8 @@ async function switchTab(tab: typeof activeTab.value) { welcomeMessage.value = config.welcome_message defaultVideoFormat.value = config.default_video_format || 'auto' defaultAudioFormat.value = config.default_audio_format || 'auto' + privacyMode.value = config.privacy_mode ?? false + privacyRetentionHours.value = config.privacy_retention_hours ?? 24 } catch { // Keep current value } @@ -66,6 +70,8 @@ async function saveSettings() { welcome_message: welcomeMessage.value, default_video_format: defaultVideoFormat.value, default_audio_format: defaultAudioFormat.value, + privacy_mode: privacyMode.value, + privacy_retention_hours: privacyRetentionHours.value, }) if (ok) { // Reload public config so main page picks up new defaults @@ -160,7 +166,7 @@ function formatFilesize(bytes: number | null): string {
- -
-

Manually trigger a purge of expired downloads.

- -
-

Rows deleted: {{ store.purgeResult.rows_deleted }}

-

Files deleted: {{ store.purgeResult.files_deleted }}

-

Files already gone: {{ store.purgeResult.files_missing }}

-

Active jobs skipped: {{ store.purgeResult.active_skipped }}

-
-
-
+ +
+ +

+ When enabled, download history, session logs, and files are automatically purged + after the configured retention period. +

+
+ +
+ + hours +
+

+ Data older than this is automatically purged (default: 24 hours). +

+
+
+ +
+ + +
+ +

+ Immediately remove expired downloads and their files from disk. + Active downloads are never affected. +

+ +
+

Rows deleted: {{ store.purgeResult.rows_deleted }}

+

Files deleted: {{ store.purgeResult.files_deleted }}

+

Files already gone: {{ store.purgeResult.files_missing }}

+

Active jobs skipped: {{ store.purgeResult.active_skipped }}

+
+
+ +
+

Displayed above the URL input on the main page. Leave empty to hide.

@@ -595,6 +640,86 @@ h3 { margin: var(--space-lg) 0; } +/* Toggle switch */ +.toggle-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + inset: 0; + background-color: var(--color-border); + border-radius: 24px; + transition: background-color 0.2s; +} + +.toggle-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: var(--color-bg); + border-radius: 50%; + transition: transform 0.2s; +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--color-accent-primary, #00a8ff); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +/* Retention input */ +.retention-setting { + margin-top: var(--space-sm); + padding-left: var(--space-sm); + border-left: 2px solid var(--color-accent-primary, #00a8ff); +} + +.retention-label { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-xs); +} + +.retention-input-row { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.retention-input { + width: 80px !important; + text-align: center; +} + +.retention-unit { + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + .password-fields { display: flex; flex-direction: column; diff --git a/frontend/src/stores/admin.ts b/frontend/src/stores/admin.ts index 1acb2aa..b5bb52d 100644 --- a/frontend/src/stores/admin.ts +++ b/frontend/src/stores/admin.ts @@ -110,7 +110,7 @@ export const useAdminStore = defineStore('admin', () => { } } - async function updateSettings(data: Record): Promise { + async function updateSettings(data: Record): Promise { isLoading.value = true try { const res = await fetch('/api/admin/settings', { diff --git a/frontend/src/tests/stores/config.test.ts b/frontend/src/tests/stores/config.test.ts index 5bfa43b..3b2925e 100644 --- a/frontend/src/tests/stores/config.test.ts +++ b/frontend/src/tests/stores/config.test.ts @@ -33,6 +33,8 @@ describe('config store', () => { max_concurrent_downloads: 3, default_video_format: 'auto', default_audio_format: 'auto', + privacy_mode: false, + privacy_retention_hours: 24, } vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)