mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
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:
parent
74ff9d3c08
commit
c3278fcac2
8 changed files with 217 additions and 27 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue