Purge intervals: hours→minutes, default ON at 1440min (24h)

- PurgeConfig: max_age_hours→max_age_minutes (default 1440)
- PurgeConfig: privacy_retention_hours→privacy_retention_minutes (default 1440)
- PurgeConfig: enabled default False→True
- PurgeConfig: cron default every minute (was daily 3am)
- Purge scheduler runs every minute for minute-granularity testing
- All API fields renamed: purge_max_age_minutes, privacy_retention_minutes
- Frontend admin panel inputs show minutes with updated labels
- Updated test assertions for new defaults
This commit is contained in:
xpltd 2026-03-21 20:33:13 -05:00
parent 0a67cb45bc
commit 43ddf43951
12 changed files with 90 additions and 56 deletions

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ vendor/
coverage/ coverage/
.cache/ .cache/
tmp/ tmp/
# ── GSD baseline (auto-generated) ──
.gsd

View file

@ -65,11 +65,11 @@ class SessionConfig(BaseModel):
class PurgeConfig(BaseModel): class PurgeConfig(BaseModel):
"""Automatic purge / cleanup settings.""" """Automatic purge / cleanup settings."""
enabled: bool = False enabled: bool = True
max_age_hours: int = 168 # 7 days max_age_minutes: int = 1440 # 24 hours
cron: str = "0 3 * * *" # 3 AM daily cron: str = "* * * * *" # every minute
privacy_mode: bool = False privacy_mode: bool = False
privacy_retention_hours: int = 24 # default when privacy mode enabled privacy_retention_minutes: int = 1440 # default when privacy mode enabled
class UIConfig(BaseModel): class UIConfig(BaseModel):

View file

@ -288,13 +288,13 @@ async def get_settings(
"default_video_format": getattr(request.app.state, "_default_video_format", "auto"), "default_video_format": getattr(request.app.state, "_default_video_format", "auto"),
"default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"), "default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"),
"privacy_mode": config.purge.privacy_mode, "privacy_mode": config.purge.privacy_mode,
"privacy_retention_hours": config.purge.privacy_retention_hours, "privacy_retention_minutes": config.purge.privacy_retention_minutes,
"max_concurrent": config.downloads.max_concurrent, "max_concurrent": config.downloads.max_concurrent,
"session_mode": config.session.mode, "session_mode": config.session.mode,
"session_timeout_hours": config.session.timeout_hours, "session_timeout_hours": config.session.timeout_hours,
"admin_username": config.admin.username, "admin_username": config.admin.username,
"purge_enabled": config.purge.enabled, "purge_enabled": config.purge.enabled,
"purge_max_age_hours": config.purge.max_age_hours, "purge_max_age_minutes": config.purge.max_age_minutes,
} }
@ -310,13 +310,13 @@ async def update_settings(
- default_video_format: str (auto, mp4, webm) - default_video_format: str (auto, mp4, webm)
- default_audio_format: str (auto, mp3, m4a, flac, wav, opus) - default_audio_format: str (auto, mp3, m4a, flac, wav, opus)
- privacy_mode: bool - privacy_mode: bool
- privacy_retention_hours: int (1-8760) - privacy_retention_minutes: int (1-525600)
- max_concurrent: int (1-10) - max_concurrent: int (1-10)
- session_mode: str (isolated, shared, open) - session_mode: str (isolated, shared, open)
- session_timeout_hours: int (1-8760) - session_timeout_hours: int (1-8760)
- admin_username: str - admin_username: str
- purge_enabled: bool - purge_enabled: bool
- purge_max_age_hours: int (1-87600) - purge_max_age_minutes: int (1-5256000)
""" """
from app.services.settings import save_settings from app.services.settings import save_settings
@ -364,12 +364,12 @@ async def update_settings(
if val and not getattr(request.app.state, "scheduler", None): if val and not getattr(request.app.state, "scheduler", None):
_start_purge_scheduler(request.app.state, config, db) _start_purge_scheduler(request.app.state, config, db)
if "privacy_retention_hours" in body: if "privacy_retention_minutes" in body:
val = body["privacy_retention_hours"] val = body["privacy_retention_minutes"]
if isinstance(val, (int, float)) and 1 <= val <= 8760: if isinstance(val, (int, float)) and 1 <= val <= 525600:
config.purge.privacy_retention_hours = int(val) config.purge.privacy_retention_minutes = int(val)
to_persist["privacy_retention_hours"] = int(val) to_persist["privacy_retention_minutes"] = int(val)
updated.append("privacy_retention_hours") updated.append("privacy_retention_minutes")
if "max_concurrent" in body: if "max_concurrent" in body:
val = body["max_concurrent"] val = body["max_concurrent"]
@ -411,12 +411,12 @@ async def update_settings(
if val and not getattr(request.app.state, "scheduler", None): if val and not getattr(request.app.state, "scheduler", None):
_start_purge_scheduler(request.app.state, config, db) _start_purge_scheduler(request.app.state, config, db)
if "purge_max_age_hours" in body: if "purge_max_age_minutes" in body:
val = body["purge_max_age_hours"] val = body["purge_max_age_minutes"]
if isinstance(val, int) and 1 <= val <= 87600: if isinstance(val, int) and 1 <= val <= 5256000:
config.purge.max_age_hours = val config.purge.max_age_minutes = val
to_persist["purge_max_age_hours"] = val to_persist["purge_max_age_minutes"] = val
updated.append("purge_max_age_hours") updated.append("purge_max_age_minutes")
# --- Persist to DB --- # --- Persist to DB ---
if to_persist: if to_persist:
@ -485,7 +485,7 @@ def _start_purge_scheduler(state, config, db) -> None:
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
scheduler.add_job( scheduler.add_job(
run_purge, run_purge,
CronTrigger(minute="*/30"), CronTrigger(minute="*"),
args=[db, config], args=[db, config],
id="purge_job", id="purge_job",
name="Scheduled purge", name="Scheduled purge",

View file

@ -28,7 +28,7 @@ async def public_config(request: Request) -> dict:
"default_video_format": getattr(request.app.state, "_default_video_format", "auto"), "default_video_format": getattr(request.app.state, "_default_video_format", "auto"),
"default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"), "default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"),
"privacy_mode": config.purge.privacy_mode, "privacy_mode": config.purge.privacy_mode,
"privacy_retention_hours": config.purge.privacy_retention_hours, "privacy_retention_minutes": config.purge.privacy_retention_minutes,
"admin_enabled": config.admin.enabled, "admin_enabled": config.admin.enabled,
"admin_setup_complete": bool(config.admin.password_hash), "admin_setup_complete": bool(config.admin.password_hash),
} }

View file

@ -42,12 +42,12 @@ async def run_purge(
privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode) privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode)
if privacy_on: if privacy_on:
retention = overrides.get( retention = overrides.get(
"privacy_retention_hours", config.purge.privacy_retention_hours "privacy_retention_minutes", config.purge.privacy_retention_minutes
) )
else: else:
retention = config.purge.max_age_hours retention = config.purge.max_age_minutes
cutoff = (datetime.now(timezone.utc) - timedelta(hours=retention)).isoformat() cutoff = (datetime.now(timezone.utc) - timedelta(minutes=retention)).isoformat()
logger.info("Purge starting: retention=%dh (privacy=%s), cutoff=%s", retention, privacy_on, cutoff) logger.info("Purge starting: retention=%dm (privacy=%s), cutoff=%s", retention, privacy_on, cutoff)
output_dir = Path(config.downloads.output_dir) output_dir = Path(config.downloads.output_dir)

View file

@ -26,14 +26,14 @@ ADMIN_WRITABLE_KEYS = {
"default_video_format", "default_video_format",
"default_audio_format", "default_audio_format",
"privacy_mode", "privacy_mode",
"privacy_retention_hours", "privacy_retention_minutes",
"max_concurrent", "max_concurrent",
"session_mode", "session_mode",
"session_timeout_hours", "session_timeout_hours",
"admin_username", "admin_username",
"admin_password_hash", "admin_password_hash",
"purge_enabled", "purge_enabled",
"purge_max_age_hours", "purge_max_age_minutes",
} }
@ -104,12 +104,12 @@ def apply_persisted_to_config(config, settings: dict) -> None:
config.admin.password_hash = settings["admin_password_hash"] config.admin.password_hash = settings["admin_password_hash"]
if "purge_enabled" in settings: if "purge_enabled" in settings:
config.purge.enabled = settings["purge_enabled"] config.purge.enabled = settings["purge_enabled"]
if "purge_max_age_hours" in settings: if "purge_max_age_minutes" in settings:
config.purge.max_age_hours = settings["purge_max_age_hours"] config.purge.max_age_minutes = settings["purge_max_age_minutes"]
if "privacy_mode" in settings: if "privacy_mode" in settings:
config.purge.privacy_mode = settings["privacy_mode"] config.purge.privacy_mode = settings["privacy_mode"]
if "privacy_retention_hours" in settings: if "privacy_retention_minutes" in settings:
config.purge.privacy_retention_hours = settings["privacy_retention_hours"] config.purge.privacy_retention_minutes = settings["privacy_retention_minutes"]
logger.info("Applied %d persisted settings to config", len(settings)) logger.info("Applied %d persisted settings to config", len(settings))
@ -123,7 +123,7 @@ def _deserialize(key: str, raw: str) -> object:
# Type coercion for known keys # Type coercion for known keys
bool_keys = {"privacy_mode", "purge_enabled"} bool_keys = {"privacy_mode", "purge_enabled"}
int_keys = {"max_concurrent", "session_timeout_hours", "purge_max_age_hours", "privacy_retention_hours"} int_keys = {"max_concurrent", "session_timeout_hours", "purge_max_age_minutes", "privacy_retention_minutes"}
if key in bool_keys: if key in bool_keys:
return bool(value) return bool(value)

31
backend/start.py Normal file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env python
"""media.rip() entrypoint — reads config and launches uvicorn with the correct port."""
import os
import sys
def main():
# Port from env var (MEDIARIP__SERVER__PORT) or default 8000
port = os.environ.get("MEDIARIP__SERVER__PORT", "8000")
host = os.environ.get("MEDIARIP__SERVER__HOST", "0.0.0.0")
sys.exit(
os.execvp(
"python",
[
"python",
"-m",
"uvicorn",
"app.main:app",
"--host",
host,
"--port",
port,
],
)
)
if __name__ == "__main__":
main()

View file

@ -173,7 +173,7 @@ class TestPublicConfig:
data = resp.json() data = resp.json()
assert data["session_mode"] == "isolated" assert data["session_mode"] == "isolated"
assert data["default_theme"] == "dark" assert data["default_theme"] == "dark"
assert data["purge_enabled"] is False assert data["purge_enabled"] is True
assert data["max_concurrent_downloads"] == 3 assert data["max_concurrent_downloads"] == 3

View file

@ -42,7 +42,7 @@ class TestPurge:
async def test_purge_deletes_old_completed_jobs(self, db, tmp_path): async def test_purge_deletes_old_completed_jobs(self, db, tmp_path):
config = AppConfig( config = AppConfig(
downloads={"output_dir": str(tmp_path)}, downloads={"output_dir": str(tmp_path)},
purge={"max_age_hours": 24}, purge={"max_age_minutes": 1440},
) )
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
@ -57,7 +57,7 @@ class TestPurge:
async def test_purge_skips_recent_completed(self, db, tmp_path): async def test_purge_skips_recent_completed(self, db, tmp_path):
config = AppConfig( config = AppConfig(
downloads={"output_dir": str(tmp_path)}, downloads={"output_dir": str(tmp_path)},
purge={"max_age_hours": 24}, purge={"max_age_minutes": 1440},
) )
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
@ -72,7 +72,7 @@ class TestPurge:
async def test_purge_skips_active_jobs(self, db, tmp_path): async def test_purge_skips_active_jobs(self, db, tmp_path):
config = AppConfig( config = AppConfig(
downloads={"output_dir": str(tmp_path)}, downloads={"output_dir": str(tmp_path)},
purge={"max_age_hours": 0}, # purge everything terminal purge={"max_age_minutes": 0}, # purge everything terminal
) )
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
@ -88,7 +88,7 @@ class TestPurge:
async def test_purge_deletes_files(self, db, tmp_path): async def test_purge_deletes_files(self, db, tmp_path):
config = AppConfig( config = AppConfig(
downloads={"output_dir": str(tmp_path)}, downloads={"output_dir": str(tmp_path)},
purge={"max_age_hours": 0}, purge={"max_age_minutes": 0},
) )
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
@ -107,7 +107,7 @@ class TestPurge:
async def test_purge_handles_missing_files(self, db, tmp_path): async def test_purge_handles_missing_files(self, db, tmp_path):
config = AppConfig( config = AppConfig(
downloads={"output_dir": str(tmp_path)}, downloads={"output_dir": str(tmp_path)},
purge={"max_age_hours": 0}, purge={"max_age_minutes": 0},
) )
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
@ -123,7 +123,7 @@ class TestPurge:
async def test_purge_mixed_statuses(self, db, tmp_path): async def test_purge_mixed_statuses(self, db, tmp_path):
config = AppConfig( config = AppConfig(
downloads={"output_dir": str(tmp_path)}, downloads={"output_dir": str(tmp_path)},
purge={"max_age_hours": 0}, purge={"max_age_minutes": 0},
) )
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())

View file

@ -76,7 +76,7 @@ export interface PublicConfig {
default_video_format: string default_video_format: string
default_audio_format: string default_audio_format: string
privacy_mode: boolean privacy_mode: boolean
privacy_retention_hours: number privacy_retention_minutes: number
admin_enabled: boolean admin_enabled: boolean
admin_setup_complete: boolean admin_setup_complete: boolean
} }

View file

@ -23,7 +23,7 @@ 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 privacyMode = ref(false)
const privacyRetentionHours = ref(24) const privacyRetentionMinutes = ref(1440)
const purgeConfirming = ref(false) const purgeConfirming = ref(false)
let purgeConfirmTimer: ReturnType<typeof setTimeout> | null = null let purgeConfirmTimer: ReturnType<typeof setTimeout> | null = null
@ -32,8 +32,8 @@ const maxConcurrent = ref(3)
const sessionMode = ref('isolated') const sessionMode = ref('isolated')
const sessionTimeoutHours = ref(72) const sessionTimeoutHours = ref(72)
const adminUsername = ref('admin') const adminUsername = ref('admin')
const purgeEnabled = ref(false) const purgeEnabled = ref(true)
const purgeMaxAgeHours = ref(168) const purgeMaxAgeMinutes = ref(1440)
// Change password state // Change password state
const currentPassword = ref('') const currentPassword = ref('')
@ -73,13 +73,13 @@ async function switchTab(tab: typeof activeTab.value) {
defaultVideoFormat.value = data.default_video_format || 'auto' defaultVideoFormat.value = data.default_video_format || 'auto'
defaultAudioFormat.value = data.default_audio_format || 'auto' defaultAudioFormat.value = data.default_audio_format || 'auto'
privacyMode.value = data.privacy_mode ?? false privacyMode.value = data.privacy_mode ?? false
privacyRetentionHours.value = data.privacy_retention_hours ?? 24 privacyRetentionMinutes.value = data.privacy_retention_minutes ?? 1440
maxConcurrent.value = data.max_concurrent ?? 3 maxConcurrent.value = data.max_concurrent ?? 3
sessionMode.value = data.session_mode ?? 'isolated' sessionMode.value = data.session_mode ?? 'isolated'
sessionTimeoutHours.value = data.session_timeout_hours ?? 72 sessionTimeoutHours.value = data.session_timeout_hours ?? 72
adminUsername.value = data.admin_username ?? 'admin' adminUsername.value = data.admin_username ?? 'admin'
purgeEnabled.value = data.purge_enabled ?? false purgeEnabled.value = data.purge_enabled ?? false
purgeMaxAgeHours.value = data.purge_max_age_hours ?? 168 purgeMaxAgeMinutes.value = data.purge_max_age_minutes ?? 1440
} }
} catch { } catch {
// Keep current values // Keep current values
@ -94,13 +94,13 @@ async function saveAllSettings() {
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_mode: privacyMode.value,
privacy_retention_hours: privacyRetentionHours.value, privacy_retention_minutes: privacyRetentionMinutes.value,
max_concurrent: maxConcurrent.value, max_concurrent: maxConcurrent.value,
session_mode: sessionMode.value, session_mode: sessionMode.value,
session_timeout_hours: sessionTimeoutHours.value, session_timeout_hours: sessionTimeoutHours.value,
admin_username: adminUsername.value, admin_username: adminUsername.value,
purge_enabled: purgeEnabled.value, purge_enabled: purgeEnabled.value,
purge_max_age_hours: purgeMaxAgeHours.value, purge_max_age_minutes: purgeMaxAgeMinutes.value,
}) })
if (ok) { if (ok) {
await configStore.loadConfig() await configStore.loadConfig()
@ -384,15 +384,15 @@ function formatFilesize(bytes: number | null): string {
<div class="retention-input-row"> <div class="retention-input-row">
<input <input
type="number" type="number"
v-model.number="privacyRetentionHours" v-model.number="privacyRetentionMinutes"
min="1" min="1"
max="8760" max="525600"
class="settings-input retention-input" class="settings-input retention-input"
/> />
<span class="retention-unit">hours</span> <span class="retention-unit">minutes</span>
</div> </div>
<p class="field-hint"> <p class="field-hint">
Data older than this is automatically purged (default: 24 hours). Data older than this is automatically purged (default: 1440 min / 24 hours).
</p> </p>
</div> </div>
</div> </div>
@ -464,12 +464,12 @@ function formatFilesize(bytes: number | null): string {
<div class="retention-input-row"> <div class="retention-input-row">
<input <input
type="number" type="number"
v-model.number="purgeMaxAgeHours" v-model.number="purgeMaxAgeMinutes"
min="1" min="1"
max="87600" max="5256000"
class="settings-input retention-input" class="settings-input retention-input"
/> />
<span class="retention-unit">hours</span> <span class="retention-unit">minutes</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -34,7 +34,7 @@ describe('config store', () => {
default_video_format: 'auto', default_video_format: 'auto',
default_audio_format: 'auto', default_audio_format: 'auto',
privacy_mode: false, privacy_mode: false,
privacy_retention_hours: 24, privacy_retention_minutes: 1440,
admin_enabled: true, admin_enabled: true,
admin_setup_complete: false, admin_setup_complete: false,
} }