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
|
enabled: bool = False
|
||||||
max_age_hours: int = 168 # 7 days
|
max_age_hours: int = 168 # 7 days
|
||||||
cron: str = "0 3 * * *" # 3 AM daily
|
cron: str = "0 3 * * *" # 3 AM daily
|
||||||
|
privacy_mode: bool = False
|
||||||
|
privacy_retention_hours: int = 24 # default when privacy mode enabled
|
||||||
|
|
||||||
|
|
||||||
class UIConfig(BaseModel):
|
class UIConfig(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,9 @@ async def manual_purge(
|
||||||
|
|
||||||
config = request.app.state.config
|
config = request.app.state.config
|
||||||
db = request.app.state.db
|
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)
|
result = await run_purge(db, config)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -207,6 +210,46 @@ async def update_settings(
|
||||||
updated.append("default_audio_format")
|
updated.append("default_audio_format")
|
||||||
logger.info("Admin updated default_audio_format to: %s", fmt)
|
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"}
|
return {"updated": updated, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,8 @@ async def public_config(request: Request) -> dict:
|
||||||
"max_concurrent_downloads": config.downloads.max_concurrent,
|
"max_concurrent_downloads": config.downloads.max_concurrent,
|
||||||
"default_video_format": overrides.get("default_video_format", "auto"),
|
"default_video_format": overrides.get("default_video_format", "auto"),
|
||||||
"default_audio_format": overrides.get("default_audio_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:
|
async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
|
||||||
"""Execute a purge cycle.
|
"""Execute a purge cycle.
|
||||||
|
|
||||||
Deletes completed/failed/expired jobs older than ``config.purge.max_age_hours``
|
When privacy_mode is active, uses privacy_retention_hours.
|
||||||
and their associated files from disk.
|
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.
|
Returns a summary dict with counts.
|
||||||
"""
|
"""
|
||||||
max_age_hours = config.purge.max_age_hours
|
overrides = getattr(config, "_runtime_overrides", {})
|
||||||
output_dir = Path(config.downloads.output_dir)
|
privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode)
|
||||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=max_age_hours)).isoformat()
|
|
||||||
|
|
||||||
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
|
# Find purgeable jobs — terminal status AND older than cutoff
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export interface PublicConfig {
|
||||||
max_concurrent_downloads: number
|
max_concurrent_downloads: number
|
||||||
default_video_format: string
|
default_video_format: string
|
||||||
default_audio_format: string
|
default_audio_format: string
|
||||||
|
privacy_mode: boolean
|
||||||
|
privacy_retention_hours: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import AdminLogin from './AdminLogin.vue'
|
||||||
const store = useAdminStore()
|
const store = useAdminStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions')
|
const activeTab = ref<'sessions' | 'storage' | 'settings'>('sessions')
|
||||||
|
|
||||||
// Session expansion state
|
// Session expansion state
|
||||||
const expandedSessions = ref<Set<string>>(new Set())
|
const expandedSessions = ref<Set<string>>(new Set())
|
||||||
|
|
@ -21,6 +21,8 @@ const welcomeMessage = ref('')
|
||||||
const defaultVideoFormat = ref('auto')
|
const defaultVideoFormat = ref('auto')
|
||||||
const defaultAudioFormat = ref('auto')
|
const defaultAudioFormat = ref('auto')
|
||||||
const settingsSaved = ref(false)
|
const settingsSaved = ref(false)
|
||||||
|
const privacyMode = ref(false)
|
||||||
|
const privacyRetentionHours = ref(24)
|
||||||
|
|
||||||
// Change password state
|
// Change password state
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
|
|
@ -54,6 +56,8 @@ async function switchTab(tab: typeof activeTab.value) {
|
||||||
welcomeMessage.value = config.welcome_message
|
welcomeMessage.value = config.welcome_message
|
||||||
defaultVideoFormat.value = config.default_video_format || 'auto'
|
defaultVideoFormat.value = config.default_video_format || 'auto'
|
||||||
defaultAudioFormat.value = config.default_audio_format || 'auto'
|
defaultAudioFormat.value = config.default_audio_format || 'auto'
|
||||||
|
privacyMode.value = config.privacy_mode ?? false
|
||||||
|
privacyRetentionHours.value = config.privacy_retention_hours ?? 24
|
||||||
} catch {
|
} catch {
|
||||||
// Keep current value
|
// Keep current value
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +70,8 @@ async function saveSettings() {
|
||||||
welcome_message: welcomeMessage.value,
|
welcome_message: welcomeMessage.value,
|
||||||
default_video_format: defaultVideoFormat.value,
|
default_video_format: defaultVideoFormat.value,
|
||||||
default_audio_format: defaultAudioFormat.value,
|
default_audio_format: defaultAudioFormat.value,
|
||||||
|
privacy_mode: privacyMode.value,
|
||||||
|
privacy_retention_hours: privacyRetentionHours.value,
|
||||||
})
|
})
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// Reload public config so main page picks up new defaults
|
// Reload public config so main page picks up new defaults
|
||||||
|
|
@ -160,7 +166,7 @@ function formatFilesize(bytes: number | null): string {
|
||||||
|
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in (['sessions', 'storage', 'purge', 'settings'] as const)"
|
v-for="tab in (['sessions', 'storage', 'settings'] as const)"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
:class="{ active: activeTab === tab }"
|
:class="{ active: activeTab === tab }"
|
||||||
@click="switchTab(tab)"
|
@click="switchTab(tab)"
|
||||||
|
|
@ -239,15 +245,54 @@ function formatFilesize(bytes: number | null): string {
|
||||||
<p v-else class="empty">Loading…</p>
|
<p v-else class="empty">Loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Purge tab -->
|
<!-- Settings tab -->
|
||||||
<div v-if="activeTab === 'purge'" class="tab-content">
|
<div v-if="activeTab === 'settings'" class="tab-content">
|
||||||
<p>Manually trigger a purge of expired downloads.</p>
|
<!-- 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
|
<button
|
||||||
@click="store.triggerPurge()"
|
@click="store.triggerPurge()"
|
||||||
:disabled="store.isLoading"
|
:disabled="store.isLoading"
|
||||||
class="btn-purge"
|
class="btn-purge"
|
||||||
>
|
>
|
||||||
{{ store.isLoading ? 'Purging…' : 'Run Purge' }}
|
{{ store.isLoading ? 'Purging…' : 'Run Purge Now' }}
|
||||||
</button>
|
</button>
|
||||||
<div v-if="store.purgeResult" class="purge-result">
|
<div v-if="store.purgeResult" class="purge-result">
|
||||||
<p>Rows deleted: {{ store.purgeResult.rows_deleted }}</p>
|
<p>Rows deleted: {{ store.purgeResult.rows_deleted }}</p>
|
||||||
|
|
@ -257,8 +302,8 @@ function formatFilesize(bytes: number | null): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings tab -->
|
<hr class="settings-divider" />
|
||||||
<div v-if="activeTab === 'settings'" class="tab-content">
|
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="welcome-msg">Welcome Message</label>
|
<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>
|
<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;
|
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 {
|
.password-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/settings', {
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ describe('config store', () => {
|
||||||
max_concurrent_downloads: 3,
|
max_concurrent_downloads: 3,
|
||||||
default_video_format: 'auto',
|
default_video_format: 'auto',
|
||||||
default_audio_format: 'auto',
|
default_audio_format: 'auto',
|
||||||
|
privacy_mode: false,
|
||||||
|
privacy_retention_hours: 24,
|
||||||
}
|
}
|
||||||
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)
|
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue