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
This commit is contained in:
xpltd 2026-03-19 05:55:08 -05:00
parent 74ff9d3c08
commit c3278fcac2
8 changed files with 217 additions and 27 deletions

View file

@ -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):

View file

@ -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"}

View file

@ -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
),
}

View file

@ -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(

View file

@ -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 {

View file

@ -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<Set<string>>(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 {
<div class="admin-tabs">
<button
v-for="tab in (['sessions', 'storage', 'purge', 'settings'] as const)"
v-for="tab in (['sessions', 'storage', 'settings'] as const)"
:key="tab"
:class="{ active: activeTab === tab }"
@click="switchTab(tab)"
@ -239,26 +245,65 @@ function formatFilesize(bytes: number | null): string {
<p v-else class="empty">Loading</p>
</div>
<!-- Purge tab -->
<div v-if="activeTab === 'purge'" class="tab-content">
<p>Manually trigger a purge of expired downloads.</p>
<button
@click="store.triggerPurge()"
:disabled="store.isLoading"
class="btn-purge"
>
{{ store.isLoading ? 'Purging…' : 'Run Purge' }}
</button>
<div v-if="store.purgeResult" class="purge-result">
<p>Rows deleted: {{ store.purgeResult.rows_deleted }}</p>
<p>Files deleted: {{ store.purgeResult.files_deleted }}</p>
<p>Files already gone: {{ store.purgeResult.files_missing }}</p>
<p>Active jobs skipped: {{ store.purgeResult.active_skipped }}</p>
</div>
</div>
<!-- Settings tab -->
<div v-if="activeTab === 'settings'" class="tab-content">
<!-- Privacy Mode -->
<div class="settings-field">
<label class="toggle-label">
<span>Privacy Mode</span>
<label class="toggle-switch">
<input type="checkbox" v-model="privacyMode" />
<span class="toggle-slider"></span>
</label>
</label>
<p class="field-hint">
When enabled, download history, session logs, and files are automatically purged
after the configured retention period.
</p>
<div v-if="privacyMode" class="retention-setting">
<label class="retention-label">Retention period</label>
<div class="retention-input-row">
<input
type="number"
v-model.number="privacyRetentionHours"
min="1"
max="8760"
class="settings-input retention-input"
/>
<span class="retention-unit">hours</span>
</div>
<p class="field-hint">
Data older than this is automatically purged (default: 24 hours).
</p>
</div>
</div>
<hr class="settings-divider" />
<!-- Manual Purge -->
<div class="settings-field">
<label>Manual Purge</label>
<p class="field-hint">
Immediately remove expired downloads and their files from disk.
Active downloads are never affected.
</p>
<button
@click="store.triggerPurge()"
:disabled="store.isLoading"
class="btn-purge"
>
{{ store.isLoading ? 'Purging…' : 'Run Purge Now' }}
</button>
<div v-if="store.purgeResult" class="purge-result">
<p>Rows deleted: {{ store.purgeResult.rows_deleted }}</p>
<p>Files deleted: {{ store.purgeResult.files_deleted }}</p>
<p>Files already gone: {{ store.purgeResult.files_missing }}</p>
<p>Active jobs skipped: {{ store.purgeResult.active_skipped }}</p>
</div>
</div>
<hr class="settings-divider" />
<div class="settings-field">
<label for="welcome-msg">Welcome Message</label>
<p class="field-hint">Displayed above the URL input on the main page. Leave empty to hide.</p>
@ -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;

View file

@ -110,7 +110,7 @@ export const useAdminStore = defineStore('admin', () => {
}
}
async function updateSettings(data: Record<string, string>): Promise<boolean> {
async function updateSettings(data: Record<string, string | boolean | number>): Promise<boolean> {
isLoading.value = true
try {
const res = await fetch('/api/admin/settings', {

View file

@ -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)